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:
1 parent
1fccc11b1d
commit
3291246425
8 files changed
+374
-48
No files matched your search
@@ -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) {
|
||||
`<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';
|
||||
}
|
||||
Reference in new issue
Block a user