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

+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