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 @@PUT IT ON THE WORLD BOARD.
+