Put a real world board behind the RANKINGS button

Ranked on KILLS IN A SINGLE RUN. Games survived is the same information at a
tenth the resolution — most players land between 5 and 20, so that board is a
wall of ties broken by who got there first. Games ride along as the tiebreak
and as a column.

The image is python:3.12-alpine running server.py now, not nginx. The board
needs one process that can write, and everything the nginx conf did here — the
no-cache headers and the manifest MIME type — is a few lines of stdlib. /data
must be a persistent volume or every deploy resets the ladder to empty.

A name belongs to whoever took it first: the client mints a random id once and
the row remembers it, so a stranger cannot overwrite your score. The id never
leaves the server. That is the whole security model and it is enough.

The prompt to sign the board appears on exactly one occasion — a run that beat
your own best kill count, on the tap that leaves the death screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-08-04 17:03:17 +02:00
1 parent 1fccc11b1d
commit 3291246425
8 files changed
+374 -48

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@
{
"name": "splatter-tennis",
"runtimeExecutable": "sh",
"runtimeArgs": ["-c", "python3 -m http.server ${PORT:-8641}"],
"runtimeArgs": ["-c", "PORT=${PORT:-8641} SPLAT_DATA=/tmp/splatter-scores python3 server.py"],
"port": 8641,
"autoPort": true
}
+2
View File
@@ -1 +1,3 @@
.env
scores.json
scores.json.tmp
+16 -9
View File
@@ -1,9 +1,16 @@
FROM nginx:alpine
# no-cache => browsers revalidate every request (ETag 304s are cheap), so a new
# deploy is picked up on plain reload. ponytail: switch to hashed filenames if traffic ever matters.
# The exact-match location gives the manifest its own MIME type — nginx's default map has no
# .webmanifest entry and Chrome refuses to install a homescreen app served as octet-stream.
# (a bare `types {}` block would have REPLACED the whole map and broken .js/.png too.)
RUN printf 'server {\n listen 80;\n root /usr/share/nginx/html;\n etag on;\n location = /manifest.webmanifest { default_type application/manifest+json; add_header Cache-Control "no-cache, must-revalidate"; }\n location / { add_header Cache-Control "no-cache, must-revalidate"; }\n}\n' > /etc/nginx/conf.d/default.conf
COPY index.html manifest.webmanifest *.js /usr/share/nginx/html/
COPY assets /usr/share/nginx/html/assets
FROM python:3.12-alpine
# Was nginx:alpine. The leaderboard needs one process that can WRITE, and nginx is not one.
# Everything the nginx conf was doing here — no-cache so a deploy is picked up on a plain reload,
# and a real MIME type for the manifest, without which Chrome refuses to install the homescreen
# app — is a few lines in server.py. Standard library only: no pip, no requirements file.
# ponytail: switch to hashed filenames if traffic ever matters.
WORKDIR /app
COPY index.html manifest.webmanifest *.js server.py /app/
COPY assets /app/assets
# THE SCORES LIVE HERE AND THIS MUST BE A PERSISTENT VOLUME. Without one the json sits in the
# container's writable layer and every deploy silently resets the leaderboard to empty — which
# looks exactly like the feature working until the first time you push.
ENV SPLAT_DATA=/data
VOLUME /data
EXPOSE 80
CMD ["python", "-u", "server.py"]
+36 -3
View File
@@ -17,12 +17,25 @@ while keeping every tennis aesthetic (umpire voice, 15/30/40 scoring, ball boys,
Zero-dependency vanilla JS, native ES modules, no build step. Mobile portrait one-handed play is
the primary target.
- **Run locally**: `preview_start` name `splatter-tennis` (python http.server :8641, `.claude/launch.json`)
- **Run locally**: `preview_start` name `splatter-tennis` (`server.py` on :8641, `.claude/launch.json`).
It is a real server now, not `http.server` — **editing `server.py` needs a preview RESTART**, and a
stale Python process looks exactly like a broken feature (it cost a debugging round).
- **Deploy**: push to `git@gitea.wuwi.one:wuwi/splatter-tennis.git` (main) → Coolify webhook builds
the Dockerfile (nginx, no-cache). Oliver tests on his phone at splatter-tennis.wuwi.one ~30s after
the Dockerfile. Oliver tests on his phone at splatter-tennis.wuwi.one ~30s after
a push. **Bump `main.js?v=N` in index.html every time** or phones keep the old build.
The Dockerfile must also COPY any new top-level file (it currently ships `index.html`,
`manifest.webmanifest`, `*.js`, `assets/`).
`manifest.webmanifest`, `*.js`, `server.py`, `assets/`).
- **The image is `python:3.12-alpine` running `server.py`, no longer `nginx:alpine`.** The world
board needs one process that can WRITE, and nginx is not one; everything the nginx conf did here
(no-cache so a deploy lands on a plain reload, and `application/manifest+json`, without which
Chrome refuses to install the homescreen app) is a few lines in `server.py`. Standard library
only — no pip, no requirements file.
- **`/data` MUST be a persistent volume** (Coolify → the resource → Storages → add a volume mount
at `/data`). Scores live in `/data/scores.json`; with no volume that file sits in the container's
writable layer and **every deploy silently resets the leaderboard to empty**, which looks exactly
like the feature working until the first time you push. Verified both ways with a local rebuild.
Staging and prod are separate resources, so they get separate volumes and separate boards — which
is what you want, or staging tests would land on the real ladder.
- **Staging**: the `staging` branch deploys to its own subdomain (own Coolify resource, same repo,
branch `staging`, same Dockerfile). Test there first, then merge to `main` for the testteam.
The branch carries NO diff against main, so it fast-forwards and merges freely — keep it that way.
@@ -1022,6 +1035,26 @@ period is a filter, not a migration. It replaced `splatterBest`, which it migrat
- `T.stats.check()` is the runnable check: it asserts the period filter and the aggregation against
a fixture, and `read()` takes an injected array precisely so it never touches a real save.
**The world board ranks KILLS IN A SINGLE RUN.** Games survived is the same information at a tenth
the resolution — most players land between 5 and 20, so a games board is a wall of ties broken by
who got there first. Games ride along as the tiebreak and as a column. The prompt to sign it appears
on exactly one occasion: a run that beat your own best kill count, on the tap that leaves the death
screen. `stats.lastRun()` carries it across `restartRun()` — it lives in the stats module, not on
`G`, which has just been restored from `PRISTINE`, and it is consumed on read so it asks once.
- **A name belongs to whoever took it first.** The client mints a random `splatterId` once and
keeps it; the row remembers it; a submission under an existing name with a different id is a 409.
That is the entire security model and it is enough: a stranger cannot overwrite your row. **The id
must never be sent to a client** — `public()` strips it, and a leak would hand anyone the name.
Clear your browser storage and you lose the name, which is what having no accounts costs.
- **One row per name, their best.** 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 is dropped SERVER-side rather than merely sorted below.
- Names come back from the wire and are written by other people. `clean()` whitelists them to
`[A-Z0-9 ._-]` and `esc()` escapes them again on the way into `innerHTML`; keep both.
- The client's `tidyName()` mirrors `clean()` exactly. When it did not, the server stored `OLIVER`,
the client remembered `oliver`, and the board silently failed to highlight the row you just wrote.
**Menu art is generated DIRECTLY, not through `_regen.py`.** That pipeline keys magenta
and slices figures out of a sheet; these are whole illustrated panels with backgrounds, so they go
through `generate_image.py` and a downscale/quantise pass, the same route the court tiles took. Two
+36 -7
View File
@@ -115,13 +115,14 @@
/* ONE overlay, four panels, swapped by #intro[data-stage]. Keeping the backdrop and the card frame
alive across every screen is the point — a screen that unmounts the whole overlay flashes the
court through between menus. */
#splash, #setup, #stats, #ranks { display: none; }
#splash, #setup, #stats, #ranks, #newbest { display: none; }
#intro[data-stage="splash"] #splash { display: flex; flex-direction: column; flex: 1; }
/* flex COLUMN, not block: it is what lets the footer's margin-top:auto reach the bottom of a
screen whose content does not fill it */
#intro[data-stage="setup"] #setup,
#intro[data-stage="stats"] #stats,
#intro[data-stage="ranks"] #ranks { display: flex; flex-direction: column; flex: 1;
#intro[data-stage="ranks"] #ranks,
#intro[data-stage="newbest"] #newbest { display: flex; flex-direction: column; flex: 1;
animation: drop 0.3s ease-out; }
/* the overlay is HIDDEN during play, not removed: a restart puts it straight back, and the
display:grid above would otherwise win over the hidden attribute */
@@ -250,6 +251,21 @@
.rkrow span { flex: 1; min-width: 0; font: 400 11px Silkscreen, monospace; color: var(--cream);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rkrow i { font: 400 11px Silkscreen, monospace; font-style: normal; color: var(--dim); }
/* ---- the name prompt. It is the only screen in the game that asks for typing, so the field is
deliberately big, centred and already focused: on a phone this appears with the keyboard
rising under it and there is no room for a second tap to find the input. */
.cent { text-align: center; }
#newbest h2 { font-size: 30px; margin-bottom: 6px; }
#nbScore { margin: 0 0 22px; font: 400 11px Silkscreen, monospace; color: var(--dim);
letter-spacing: 1px; }
#nbScore b { color: var(--acid); font-weight: 400; }
#nbName { display: block; width: 100%; box-sizing: border-box; margin: 0 auto 10px;
padding: 14px 10px; background: var(--solid); border: 2px solid var(--line);
border-radius: 0; color: var(--acid); font: 700 20px Silkscreen, monospace;
letter-spacing: 3px; text-align: center; text-transform: uppercase;
-webkit-appearance: none; -webkit-tap-highlight-color: transparent; }
#nbName:focus { outline: none; border-color: var(--acid); }
#nbName::placeholder { color: var(--dim); letter-spacing: 2px; font-size: 14px; }
</style>
</head>
<body>
@@ -271,7 +287,7 @@
<button class="btn big" id="mStart">PLAY MATCH</button>
<div class="two">
<button class="btn sec" id="mStats">STATS<i id="mKills">0 KILLS</i></button>
<button class="btn sec" id="mRanks">RANKINGS<i>SEASON 1</i></button>
<button class="btn sec" id="mRanks">RANKINGS<i>WORLD BOARD</i></button>
</div>
<p id="build"></p>
</div>
@@ -304,14 +320,27 @@
<div id="stW"></div>
<div class="foot" id="stFoot"></div>
</div>
<!-- placeholder ladder until the leaderboard task lands — see stats.drawRanks -->
<div id="ranks">
<div class="hdr"><button class="chev" id="rkBack">&lsaquo;</button><h2>RANKINGS</h2></div>
<h3>SEASON 1<span>UNRANKED · OFFLINE</span></h3>
<div class="hdr"><button class="chev" id="rkBack">&lsaquo;</button><h2>WORLD BOARD</h2></div>
<h3>BEST SINGLE RUN<span>KILLS · GAMES</span></h3>
<div id="rkList"></div>
</div>
<!-- shown only when a run beats your own best kill count — see main.js askName -->
<div id="newbest">
<div class="mid">
<h2 class="cent">NEW PERSONAL BEST</h2>
<p id="nbScore"></p>
<input id="nbName" maxlength="12" placeholder="YOUR NAME" autocomplete="off"
autocapitalize="characters" spellcheck="false">
<p class="none cent" id="nbMsg">PUT IT ON THE WORLD BOARD.</p>
</div>
<div class="foot bare">
<button class="btn big" id="nbSend">SIGN THE BOARD</button>
<button class="btn" id="nbSkip">NOT THIS TIME</button>
</div>
</div>
</div>
</div>
<script type="module" src="main.js?v=123"></script>
<script type="module" src="main.js?v=124"></script>
</body>
</html>
+47 -3
View File
@@ -131,7 +131,46 @@ let stPeriod = 'all';
const openStats = () => { stats.drawStats(stPeriod, SPRV); go('stats'); };
document.getElementById('mStart').addEventListener('click', () => go('setup'));
document.getElementById('mStats').addEventListener('click', openStats);
document.getElementById('mRanks').addEventListener('click', () => { stats.drawRanks(); go('ranks'); });
document.getElementById('mRanks').addEventListener('click', () => { go('ranks'); stats.drawRanks(); });
// --- THE NAME PROMPT. It appears on exactly one occasion: a run that beat your own best kill
// count, on the tap that takes you off the death screen. Anything more often is a form standing
// between you and the next match.
const nbName = document.getElementById('nbName'), nbMsg = document.getElementById('nbMsg');
let nbRun = null;
function askName(run) {
nbRun = run;
document.getElementById('nbScore').innerHTML =
`<b>${run.kills.toLocaleString('en-GB')}</b> KILLS · <b>${run.games}</b> GAME${run.games === 1 ? '' : 'S'}`;
nbName.value = stats.savedName();
nbMsg.textContent = 'PUT IT ON THE WORLD BOARD.';
document.getElementById('nbSend').disabled = false;
go('newbest');
// focus only where a keyboard is already there. On a phone this would throw the soft keyboard up
// over the score you just earned, before you have read it.
if (!matchMedia('(pointer: coarse)').matches) nbName.focus();
}
document.getElementById('nbSkip').addEventListener('click', () => { nbRun = null; go('splash'); });
document.getElementById('nbSend').addEventListener('click', async () => {
const name = stats.tidyName(nbName.value);
if (!name) { nbMsg.textContent = 'A NAME FIRST.'; nbName.focus(); return; }
nbName.value = name; // show what is actually going on the board
if (!nbRun) return go('splash');
const btn = document.getElementById('nbSend');
btn.disabled = true; nbMsg.textContent = 'SENDING…'; // the double-tap posts twice otherwise
try {
const r = await stats.submit(name, nbRun.kills, nbRun.games);
nbRun = null;
go('ranks');
stats.drawRanks(r.scores); // the board came back with the reply — no second round trip
} catch (e) {
btn.disabled = false;
nbMsg.textContent = e.taken ? 'THAT NAME IS SPOKEN FOR. PICK ANOTHER.'
: 'THE BOARD DID NOT ANSWER. TRY AGAIN?';
if (e.taken) { nbName.select(); nbName.focus(); }
}
});
nbName.addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('nbSend').click(); });
document.getElementById('cStart').addEventListener('click', start);
for (const id of ['sBack', 'stBack', 'rkBack'])
document.getElementById(id).addEventListener('click', () => go('splash'));
@@ -190,8 +229,13 @@ addEventListener('pagehide', () => {
const maybeRestart = () => {
if (G.modal || G.mode !== 'eaten' || G.eatenT <= EATEN_RESTART) return;
restartRun();
refreshSplash(); // the run just ended: both splash figures have moved
intro.hidden = false; go('splash');
refreshSplash(); // the run just ended: the splash count has moved
intro.hidden = false;
// stats.lastRun() survives restartRun on purpose — it lives in the stats module, not on G, which
// has just been restored from PRISTINE. Consumed on read, so the prompt appears once.
const run = stats.lastRun();
if (run && run.newKillBest && run.kills > 0) askName(run);
else go('splash');
};
cv.addEventListener('pointerdown', maybeRestart);
addEventListener('keydown', maybeRestart);
+153
View File
@@ -0,0 +1,153 @@
#!/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 json, os, 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))
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)
def do_GET(self):
if self.path.split('?')[0] == '/api/scores':
with lock:
return self.reply(200, {'scores': public(load())})
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 not self.path.startswith('/api/'):
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()
+83 -25
View File
@@ -79,13 +79,20 @@ export function multiKill() { run.mk++; }
export function endRun(games, sets, secs, dead = true) {
const prev = load();
const best = prev.reduce((b, r) => r.g > b.games ? { games: r.g, sets: r.s } : b, { games: 0, sets: 0 });
if (banked || (!run.k && !games)) { run = bag(); return { ...best, newBest: false }; }
// TWO bests, because they answer different questions. `newBest` is GAMES and belongs to the death
// summary, which has always celebrated how long you lasted. `newKillBest` is KILLS, which is what
// the world board ranks — so it is the one that earns you a name-entry prompt.
const bestKills = prev.reduce((m, r) => Math.max(m, r.k || 0), 0);
const kills = run.k;
if (banked || (!run.k && !games)) { run = bag(); return { ...best, newBest: false, newKillBest: false }; }
const newBest = games > best.games;
const newKillBest = kills > bestKills;
prev.push({ t: Date.now(), g: games, s: sets, d: Math.round(secs || 0),
k: run.k, mk: run.mk, dead: dead ? 1 : 0, z: run.z, w: run.w });
k: kills, mk: run.mk, dead: dead ? 1 : 0, z: run.z, w: run.w });
save(prev);
run = bag(); banked = true;
return { ...best, newBest };
last = { kills, games, newKillBest };
return { ...best, newBest, newKillBest, kills, games };
}
// ------------------------------------------------------------- aggregation ----
@@ -110,12 +117,62 @@ export function total() {
return { kills: a.kills, best: a.longest };
}
// ponytail: a PLACEHOLDER rank until the leaderboard task lands. Deterministic in your own kill
// count, so it never reshuffles between opens, and shaped like a real ladder — steep at the bottom,
// flattening out. It means nothing and is not stored anywhere.
export const rank = kills => Math.max(1, Math.ceil(48000 * Math.exp(-kills / 900)));
// ------------------------------------------------------------ leaderboard ----
// The world board, ranked on KILLS IN A SINGLE RUN — the game's own currency, and fine-grained
// enough that the order means something (games survived is a small integer and would be a wall of
// ties). Games ride along as the tiebreak and as a column.
// Two routes, no auth. See server.py: the board is a wall for writing your name on.
const NAME_KEY = 'splatterName', ID_KEY = 'splatterId';
export const savedName = () => { try { return localStorage.getItem(NAME_KEY) || ''; } catch { return ''; } };
// Your claim on your name. Minted once, never shown, never leaves this device except to prove a
// submission is yours — see server.py. It is not a login and it protects nothing but the row: lose
// your browser storage and you lose the name, which is what having no accounts costs.
function myId() {
try {
let v = localStorage.getItem(ID_KEY);
if (!v) { v = Array.from(crypto.getRandomValues(new Uint8Array(12)))
.map(b => b.toString(36).padStart(2, '0')).join(''); localStorage.setItem(ID_KEY, v); }
return v;
} catch { return ''; } // private mode: no claim, and the first stranger to use the name keeps it
}
// The run just flushed, held here rather than on G — G is restored from PRISTINE by restartRun(),
// and the name prompt has to survive exactly that. Consumed on read so it prompts once.
let last = null;
export function lastRun() { const r = last; last = null; return r; }
export async function board() {
const r = await fetch('api/scores', { cache: 'no-store' });
if (!r.ok) throw new Error('board ' + r.status);
return (await r.json()).scores || [];
}
// The SAME normalisation server.py's clean() applies. Both ends have to agree or nothing lines up:
// the server stored OLIVER, the client remembered "oliver", and the board then failed to highlight
// the row you had just written. Normalising here also means the prefill shows what will really
// appear on the board.
export const tidyName = s =>
String(s).toUpperCase().replace(/[^A-Z0-9 ._-]/g, '').trim().slice(0, 12);
export async function submit(rawName, kills, games) {
const name = tidyName(rawName);
if (!name) throw new Error('empty name');
try { localStorage.setItem(NAME_KEY, name); } catch {} // type it once, ever
const r = await fetch('api/scores', { method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ n: name, k: kills, g: games, id: myId() }) });
if (r.status === 409) { const e = new Error('name taken'); e.taken = true; throw e; }
if (!r.ok) throw new Error('submit ' + r.status);
return r.json(); // {ok, rank, scores}
}
const n = x => x.toLocaleString('en-GB');
// Names come off the wire and are written by other people. server.py whitelists them down to
// [A-Z0-9 ._-] so nothing dangerous can survive the round trip — this is the second lock on the
// same door, because the alternative is trusting a remote string into innerHTML.
const esc = s => String(s).replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const wIcon = id => id === 'ball' ? 'w_ball' : WEAPONS[id] ? WEAPONS[id].icon : null;
// ----------------------------------------------------------------- screens ----
@@ -153,24 +210,20 @@ export function drawStats(period, v) {
`<b>LONGEST RUN</b><i>${d.longest} GAME${d.longest === 1 ? '' : 'S'}</i>`;
}
// ponytail: the ladder is FAKE — filler names around your own entry, seeded off your kill count so
// the table is stable between opens. It exists so the button is not a dead end before the real
// leaderboard task; delete this whole function and the #ranks stage when that lands.
const FILLER = ['DEUCE_COURT', 'NETCORD', 'ADVANTAGE', 'BREAKPOINT', 'LOVEGAME', 'TIEBREAK',
'DROPSHOT', 'BASELINE', 'ACEHOLE', 'TOPSPIN', 'LETCALL', 'SMASHER'];
export function drawRanks() {
const { kills } = total(), me = rank(kills);
const rows = [];
for (let i = -4; i <= 4; i++) {
if (i === 0) { rows.push({ r: me, name: 'YOU', k: kills, me: true }); continue; }
const r = Math.max(1, me + i * (1 + (me % 7)));
if (rows.some(x => x.r === r) || r === me) continue;
// deterministic in the rank itself, so the same neighbours come back every time
rows.push({ r, name: FILLER[r % FILLER.length] + (r % 97), k: Math.max(0, Math.round(kills - i * 37)) });
// The real board. Async, and every failure lands on a line of copy rather than an empty screen —
// the server being down must never be indistinguishable from nobody having played.
export async function drawRanks(rows) {
const el = document.getElementById('rkList');
if (!rows) {
el.innerHTML = '<p class="none">READING THE BOARD…</p>';
try { rows = await board(); }
catch { el.innerHTML = '<p class="none">THE BOARD IS UNREACHABLE.</p>'; return; }
}
rows.sort((a, b) => a.r - b.r);
document.getElementById('rkList').innerHTML = rows.map(x =>
`<div class="rkrow${x.me ? ' me' : ''}"><b>#${n(x.r)}</b><span>${x.name}</span><i>${n(x.k)}</i></div>`).join('');
if (!rows.length) { el.innerHTML = '<p class="none">NOBODY IS ON THE BOARD YET. BE FIRST.</p>'; return; }
const me = savedName();
el.innerHTML = rows.map((r, i) =>
`<div class="rkrow${r.n === me ? ' me' : ''}"><b>${n(i + 1)}</b><span>${esc(r.n)}</span>`
+ `<i>${n(r.k || 0)} · ${r.g || 0}G</i></div>`).join('');
}
// ------------------------------------------------------------------ check ----
@@ -194,6 +247,11 @@ export function check() {
eq(last.kills, 1, 'last kills');
eq(last.multi, 2, 'last multi');
eq(Object.entries(all.z).sort((a, b) => b[1] - a[1])[0][0], 'a', 'sort');
eq(rank(0) > rank(5000), true, 'rank improves with kills');
// tidyName has to agree with server.py's clean() or the board cannot highlight your own row —
// it is the one rule duplicated across two languages, so it is the one worth pinning down.
eq(tidyName(' oliver '), 'OLIVER', 'name upper+trim');
eq(tidyName('<b>hi</b>'), 'BHIB', 'name strips markup');
eq(tidyName('ABCDEFGHIJKLMNOPQRSTUVWXYZ').length, 12, 'name capped at 12');
eq(tidyName('...'), '...', 'name keeps the allowed punctuation');
return 'ok';
}