diff --git a/.claude/launch.json b/.claude/launch.json index 38f6d1d..456e23a 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -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 } diff --git a/.gitignore b/.gitignore index 4c49bd7..6b6b1fb 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ .env +scores.json +scores.json.tmp diff --git a/Dockerfile b/Dockerfile index dff3b79..bd12467 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/HANDOFF.md b/HANDOFF.md index 95b0f9f..71af214 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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 diff --git a/index.html b/index.html index 663eac7..1949a99 100644 --- a/index.html +++ b/index.html @@ -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; } @@ -271,7 +287,7 @@
- +

@@ -304,14 +320,27 @@
-
-

RANKINGS

-

SEASON 1UNRANKED · OFFLINE

+

WORLD BOARD

+

BEST SINGLE RUNKILLS · GAMES

+ +
+
+

NEW PERSONAL BEST

+

+ +

PUT IT ON THE WORLD BOARD.

+
+
+ + +
+
- + diff --git a/main.js b/main.js index 3d87ce4..6896577 100644 --- a/main.js +++ b/main.js @@ -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 = + `${run.kills.toLocaleString('en-GB')} KILLS · ${run.games} 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); diff --git a/server.py b/server.py new file mode 100644 index 0000000..e66f7ea --- /dev/null +++ b/server.py @@ -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() diff --git a/stats.js b/stats.js index 5ae7b3f..81b4151 100644 --- a/stats.js +++ b/stats.js @@ -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 => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const wIcon = id => id === 'ball' ? 'w_ball' : WEAPONS[id] ? WEAPONS[id].icon : null; // ----------------------------------------------------------------- screens ---- @@ -153,24 +210,20 @@ export function drawStats(period, v) { `LONGEST RUN${d.longest} GAME${d.longest === 1 ? '' : 'S'}`; } -// 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 = '

READING THE BOARD…

'; + try { rows = await board(); } + catch { el.innerHTML = '

THE BOARD IS UNREACHABLE.

'; return; } } - rows.sort((a, b) => a.r - b.r); - document.getElementById('rkList').innerHTML = rows.map(x => - `
#${n(x.r)}${x.name}${n(x.k)}
`).join(''); + if (!rows.length) { el.innerHTML = '

NOBODY IS ON THE BOARD YET. BE FIRST.

'; return; } + const me = savedName(); + el.innerHTML = rows.map((r, i) => + `
${n(i + 1)}${esc(r.n)}` + + `${n(r.k || 0)} · ${r.g || 0}G
`).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('hi'), 'BHIB', 'name strips markup'); + eq(tidyName('ABCDEFGHIJKLMNOPQRSTUVWXYZ').length, 12, 'name capped at 12'); + eq(tidyName('...'), '...', 'name keeps the allowed punctuation'); return 'ok'; }