The leagues, phase 1: the profile clock ticks — xp, levels, the identity screen, the server mirror

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-29 12:22:33 +02:00
1 parent bc10216d0b
commit 9133c0d351
12 files changed
+507 -19

No files matched your search

+3
View File
@@ -1,6 +1,9 @@
.env
scores.json
scores.json.tmp
profiles.json
profiles.json.tmp
__pycache__/
assets/_gen/
assets/_refcheck/
assets/__pycache__/
+35 -1
View File
@@ -491,7 +491,41 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu
`RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud
FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone.
## THE LEAGUES — the meta-progression plan (2026-08-29, AGREED, NOT YET BUILT)
## Session log: THE LEAGUES, PHASE 1 — profile, xp, the identity screen (2026-08-29, STAGING ONLY)
Phase 1 of the plan below, built exactly to it. **STAGING ONLY for now** (Oliver: he tests himself
before the wider group) — do not merge to main until his verdict.
- **`src/profile.js`** is the profile: `{xp, lg, ch, setup}` in localStorage `splatterProfile`,
mirrored to the server (`/api/profile`, keyed by stats' existing client id). Name/tour/kit/skin
deliberately KEEP their existing homes and are only mirrored outbound, so nothing has two owners.
The mirror never goes backward on xp (server-side guard) and `pull()` adopts it only when ahead —
a wiped phone gets its identity AND its number back.
- **XP hooks are deliberately few**: `advancePoint` (the one place every mode's point converges —
points and games cannot be missed or double-counted), the `killZombie` funnel, duel win, cup
round, cup won. `award()` carries the no-stakes guard ITSELF (drill, bench, testMode, training
court), so a call site cannot forget it. Curve: `80·(l−1)^1.8`, cap 50 — first levels inside the
first run, late ones sessions apart. All dials in `profile.XP`.
- **UI**: the splash carries a LEVEL strip (#xpRow, filled by refreshSplash); the death/summary
screen shows `+N XP · LEVEL a → b` plus a progress bar (render.js, beside the best-run lines).
A mid-run level-up announces as a CARD (popup policy: never a modal on a live court).
- **THE IDENTITY SCREEN** (`#player`, first `PLAY MATCH` tap only): name prefilled with a ZOMBIE
NAME (`profile.zombieName()`, two-part roll, always ≤12 chars and board-legal), a die button
rolls another; tour/kit/skin pickers write the SAME picked* state and localStorage keys the
setup screen reads. **`syncSetup()` (called by `go('setup')`) repaints the setup rows from
picked\*** — without it the rows only tracked their own taps and showed the boot-time selection
after an identity-screen change (caught live in the pane).
- **Server**: `/api/profile` GET/POST beside the scores API, same stance (whitelist re-derive,
bounded, atomic write, 5000 profiles cap, oldest-touched evicted). `profiles.json` beside
`scores.json` under `/data` — the volume that already exists.
- **Drive-by fix**: `pagehide` now returns on `G.drill` — drill kills leak into the stats run bag
(tally sits above the drill hook in killZombie) and a tab close mid-drill banked them as a gore
run. Death was already guarded; the tab-close path was not.
- Tests: +4 (curve shape, the award funnel + every mute, run-delta reset, name legality), suite
118, seeds fuzzed. Pane-verified end to end (identity flow, POST 200, death screen with +16 XP
and the bar; NOTE this pane session has rAF dead again — `T.step(1/60)` loops drove the clock).
## THE LEAGUES — the meta-progression plan (2026-08-29, AGREED, PHASE 1 BUILT — see log above)
Worked out with Oliver in discussion, decisions settled below. The goal in his words: an incentive
to keep opening the game beyond beating the next opponent — levelling through play, with a clearly
+53 -8
View File
@@ -174,7 +174,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
/* 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, #newbest, #secret, #bosses, #stmode, #stsub, #bracket, #vs, #train { display: none; }
#splash, #setup, #stats, #ranks, #newbest, #secret, #bosses, #stmode, #stsub, #bracket, #vs, #train, #player { 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 */
@@ -188,6 +188,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
#intro[data-stage="bracket"] #bracket,
#intro[data-stage="train"] #train,
#intro[data-stage="vs"] #vs,
#intro[data-stage="player"] #player,
#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
@@ -236,22 +237,35 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
font-style: normal; color: var(--dim); letter-spacing: 0.5px; }
/* THE PLAYER BLOCK: the live man on the left, everything that changes him on the right, so a tap
and its result are never more than a thumb apart and the whole of "who am I" is one screenful. */
#who { display: flex; gap: 12px; align-items: flex-start; }
#whoCtl { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
#who, #who2 { display: flex; gap: 12px; align-items: flex-start; }
#whoCtl, #pCtl { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
/* The live player. `position: static` is not decoration: the bare `canvas` rule above is the COURT
— fixed, full-bleed, 100dvh — and it catches any canvas in the document, so this one flew to the
top-left corner of the screen until it was told otherwise. Every geometry property that rule
sets has to be undone here. The buffer is the sprite's own 96x224, so width follows height. */
#look { position: static; display: block; flex: none; margin: 0;
#look, #pLook { position: static; display: block; flex: none; margin: 0;
width: auto; height: 176px; touch-action: auto; image-rendering: pixelated; }
/* the tour is now TWO WORDS, not two portraits — the live player next to them is the picture, and
a second drawing of the same man (front-facing, and deaf to every colour on this screen) was
both a duplicate and the reason the court list started below the fold */
#tours { display: flex; gap: 7px; }
#tours button { flex: 1; padding: 10px 2px; background: var(--panel); border: 2px solid var(--line);
#tours, #pTours { display: flex; gap: 7px; }
#tours button, #pTours button { flex: 1; padding: 10px 2px; background: var(--panel); border: 2px solid var(--line);
color: var(--dim); font: 400 10px Silkscreen, monospace; letter-spacing: 1px;
cursor: pointer; -webkit-tap-highlight-color: transparent; }
#tours button.on { border-color: var(--acid); background: rgba(188,212,85,0.09); color: var(--cream); }
#tours button.on, #pTours button.on { border-color: var(--acid); background: rgba(188,212,85,0.09); color: var(--cream); }
/* THE IDENTITY SCREEN (the leagues, phase 1): name row = the input plus the die. */
#nameRow { display: flex; gap: 8px; }
#nameRow .tin { flex: 1; margin: 0; }
#pDice { width: 52px; flex: none; margin: 0; padding: 0; font-size: 20px; line-height: 1; }
/* THE LEVEL STRIP: the one meta number over a thin bar, riding the splash foot. The fill colour
is the acid accent for now; the leagues will hand it the skill colour in phase 3. */
#xpRow { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; }
#xpRow u { font: 700 9px Silkscreen, monospace; color: var(--cream); text-decoration: none;
letter-spacing: 1px; white-space: nowrap; }
#xpRow s { flex: 1; height: 6px; background: rgba(255,255,255,0.10); border: 1px solid var(--line);
text-decoration: none; display: block; }
#xpRow s i { display: block; height: 100%; width: 0%; background: var(--acid);
transition: width 0.4s ease; }
/* KIT and SKIN: a row of colour squares, under a label that names whatever is selected. That is
the whole UI — the pick has no meters and no art to show, and a labelled tile per colour would
be five times the screen for a choice you make by looking at the colour. */
@@ -503,6 +517,9 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
you can press before the sheets have landed is how you reach the stats screen with half
the zombies missing. Swapped by #splash.busy, see main.js. -->
<div id="boot"><s><i></i></s><u>LOADING</u></div>
<!-- THE LEVEL STRIP (the leagues): the clock the whole meta-game runs on, visible on every
visit to the title. Filled by refreshSplash. -->
<div id="xpRow"><u id="xpLv">LEVEL 1</u><s><i id="xpFill"></i></s></div>
<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>
@@ -552,6 +569,34 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
<button class="btn big" id="cStart">SERVE UP</button>
</div>
</div>
<!-- THE IDENTITY (the leagues, phase 1): the first boot asks who walks on — a name (prefilled
with a zombie name, the die rolls another), the tour and the colours. Everything here
writes the SAME stores the setup screen reads (stats' name key, the picker localStorage
keys), so the two screens cannot disagree about who you are. Rebuilt on every visit by
main.buildPlayer, so the .on states track the setup screen's. -->
<div id="player">
<div class="hdr"><button class="chev" id="pBack">&lsaquo;</button><h2>YOUR PLAYER</h2></div>
<h3>NAME<span>YOURS ON EVERY BOARD</span></h3>
<div id="nameRow">
<input id="pName" class="tin" maxlength="12" autocomplete="off" spellcheck="false">
<button class="btn" id="pDice" aria-label="roll a zombie name">&#127922;</button>
</div>
<h3>THE BODY</h3>
<div id="who2">
<canvas id="pLook" width="96" height="224"></canvas>
<div id="pCtl">
<div id="pTours"></div>
<u class="swlab">KIT<i id="pKitName"></i></u>
<div id="pKitRow" class="swrow"></div>
<u class="swlab">SKIN<i id="pSkinName"></i></u>
<div id="pSkinRow" class="swrow"></div>
</div>
</div>
<div class="foot">
<p class="cent">THE CLUB WILL LEARN YOUR NAME.</p>
<button class="btn big" id="pGo">ENTER THE CLUB</button>
</div>
</div>
<!-- THE DRAW (cup mode only). SERVE UP lands here instead of on a court: the four rounds roll
through their own band of the ladder and stop, and what you watched land is what walks on
(main.js hands the settled rungs to startMatch). The rows are built in main.js from
@@ -675,6 +720,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div>
</div>
</div>
<script type="module" src="src/main.js?v=311"></script>
<script type="module" src="src/main.js?v=312"></script>
</body>
</html>
+82 -1
View File
@@ -37,6 +37,55 @@ NAME_MAX = 12
MODES = ('g', 'z', 'c')
lock = threading.Lock()
# ---- THE PROFILE MIRROR (2026-08-29, the leagues). localStorage alone dies with cleared browser
# data, and the meta-progression asks for hours of investment — so the client mirrors its profile
# here, keyed by the same random id that owns board names. This is a BACKUP, not an account: the
# client is the authority while it has one, and adopts the mirror only when its own storage is
# empty (or behind on xp). Same stance as the scores: no auth beyond the id, nothing here may
# crash, hang or grow without bound, every field re-derived before it touches disk.
PROFILE_FILE = os.path.join(DATA, 'profiles.json')
PROFILE_KEEP = 5000 # profiles kept; oldest-touched evicted past this
plock = threading.Lock()
def pload():
try:
with open(PROFILE_FILE) as f:
d = json.load(f)
return d if isinstance(d, dict) else {}
except (OSError, ValueError):
return {}
def psave(d):
os.makedirs(DATA, exist_ok=True)
tmp = PROFILE_FILE + '.tmp'
with open(tmp, 'w') as f:
json.dump(d, f)
os.replace(tmp, PROFILE_FILE)
def clean_profile(payload):
"""(id, profile) or None. Whitelisted and bounded — this file is read back and rendered."""
if not isinstance(payload, dict) or not isinstance(payload.get('p'), dict):
return None
who = re.sub(r'[^a-z0-9]', '', str(payload.get('id', '')))[:40]
if not who:
return None
p = payload['p']
word = lambda k, n=12: re.sub(r'[^a-z0-9_]', '', str(p.get(k, '')))[:n]
try:
out = {'v': 1,
'xp': max(0, min(int(p.get('xp', 0)), 1_000_000_000)),
'lg': max(1, min(int(p.get('lg', 1)), 5)),
'ch': 1 if p.get('ch') else 0,
'setup': 1 if p.get('setup') else 0,
'n': re.sub(r'[^A-Z0-9 ._-]', '', str(p.get('n', '')).upper()).strip()[:NAME_MAX],
'tour': word('tour'), 'kit': word('kit'), 'skin': word('skin')}
except (TypeError, ValueError):
return None
return who, out
def mode_of(r):
return r.get('m') or 'g'
@@ -234,6 +283,14 @@ class Handler(SimpleHTTPRequestHandler):
with lock:
return self.reply(200, {'m': want,
'scores': public([r for r in load() if mode_of(r) == want])})
if path == '/api/profile':
who = re.sub(r'[^a-z0-9]', '', (self.path.split('id=')[1].split('&')[0]
if 'id=' in self.path else ''))[:40]
if not who:
return self.reply(400, {'error': 'no id'})
with plock:
row = pload().get(who)
return self.reply(200, {'ok': True, 'p': row and row.get('p')})
# Don't hand out the source. Nothing secret lives in it — the .env is not in the image and
# the client is public JS regardless — but serving server.py and the asset-pipeline scripts
# to anyone who guesses the name is untidy, and it publishes the exact validation rules.
@@ -242,7 +299,8 @@ class Handler(SimpleHTTPRequestHandler):
return super().do_GET()
def do_POST(self):
if self.path.split('?')[0] != '/api/scores':
path = self.path.split('?')[0]
if path not in ('/api/scores', '/api/profile'):
return self.send_error(404)
try:
n = int(self.headers.get('Content-Length') or 0)
@@ -250,6 +308,29 @@ class Handler(SimpleHTTPRequestHandler):
return self.reply(400, {'error': 'bad length'})
if not 0 < n <= MAX_BODY:
return self.reply(400, {'error': 'bad length'})
if path == '/api/profile':
try:
got = clean_profile(json.loads(self.rfile.read(n)))
except ValueError:
got = None
if not got:
return self.reply(400, {'error': 'bad profile'})
who, prof = got
with plock:
d = pload()
# the mirror never goes BACKWARD on xp: a second device with a stale localStorage
# boots, finds nothing local, adopts the mirror — but if it posts before adopting
# (or a wiped phone posts a fresh profile), a lower xp must not overwrite the
# investment the mirror exists to protect.
old = d.get(who)
if old and old.get('p', {}).get('xp', 0) > prof['xp']:
return self.reply(200, {'ok': True, 'p': old['p']})
d[who] = {'p': prof, 't': int(time.time())}
if len(d) > PROFILE_KEEP:
for k in sorted(d, key=lambda k: d[k].get('t', 0))[:len(d) - PROFILE_KEEP]:
del d[k]
psave(d)
return self.reply(200, {'ok': True, 'p': prof})
try:
row = clean(json.loads(self.rfile.read(n)))
except ValueError:
+4
View File
@@ -9,6 +9,7 @@
import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage, CUP_ROUNDS } from './config.js';
import { endRun, MODE_MATCH } from './stats.js';
import { award } from './profile.js';
import { G, P, O, ball, score, setupPoint, sendBoy, other } from './state.js';
import { voice, zombieDuck, aww } from './audio.js';
import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js';
@@ -848,6 +849,7 @@ function cupGameWon(zb, banked = false) {
return;
}
// the round is won: the corpse goes down, and THEN comes apart (the kill fall, as the deathmatch)
award('round');
d.afterFall = () => {
matchHealBeat();
cup.round++; cup.g = 0;
@@ -874,6 +876,7 @@ function cupWon() {
G.interlude = false;
ball.active = false; G.flash = null; G.modal = null; G.card = null;
cheer(4, 1); voice('unbelievable', 0.4);
award('cup');
const games = score.games[0] + score.games[1];
const b = endRun(CUP_ROUNDS.length, games, G.splatT, false, MODE_MATCH);
G.best = b; G.newBest = b.newBest;
@@ -947,6 +950,7 @@ export function resolveInterlude(side) {
ball.active = false; G.phase = 'over'; G.overT = 2.2;
if (side === 'P') {
cheer(3, 0.7);
award('duelWin');
deliverTin(); // win the point, win the next tin
// ...and the trainer comes out. THE reason to want a duel rather than merely tolerate it — it is
// the only healing in the game, and blood is the only thing you cannot buy back otherwise. It
+7
View File
@@ -8,6 +8,7 @@ import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js';
import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
import { updateDrill } from './drill.js';
import { endRun, beginRun, MODE_CLASSIC } from './stats.js';
import { award, beginRunXp } from './profile.js';
import { updateZombies, updateParts, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam , pops, popText } from './zombies.js';
import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog, updateBench, benchKey, tickMatchFall, placeReceiver } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing
import { netSendShot, netSendToss, netTick, netJudging, netSendPoint, netSendFault, netClose } from './net.js'; // runtime-only cycle: net calls back into pointTo/fault/tossBall/showModal
@@ -649,8 +650,13 @@ export const machCap = () => Math.min(TUNE.machMaxCap,
export function advancePoint(side) { // shared scoring math; returns whether a game was won
const i = side === 'P' ? 0 : 1, j = 1 - i;
score.pts[i]++;
// THE ONE XP HOOK for points and games: every mode's point converges here (pointTo, matchPoint,
// splatPoint), so the leagues' clock cannot miss a mode or double-count one. award() itself
// mutes the no-stakes modes (training, drill, bench, test).
if (side === 'P') award('point');
if (score.pts[i] >= 4 && score.pts[i] - score.pts[j] >= 2) {
score.games[i]++; score.pts = [0, 0];
if (side === 'P') award('game');
return true;
}
return false;
@@ -998,6 +1004,7 @@ export function restartRun() {
netClose(); // netplay: resetState wipes G.net below; the socket must not outlive the run it carried
resetZombies(); resetInput(); resetState(); setupPoint();
beginRun(); // ...and the stats bag, which is the only state that does not live on G
beginRunXp(); // ...and the profile's run delta, same rule (the xp itself is already banked)
}
export function forceZombies() {
+86 -6
View File
@@ -14,6 +14,7 @@ import { zombies, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss
import { cv } from './dom.js';
import { haptic } from './haptics.js';
import * as stats from './stats.js';
import * as profile from './profile.js';
import './input.js';
let last = performance.now();
@@ -80,7 +81,10 @@ const menuUp = () => !intro.hidden;
const remembered = k => { try { return localStorage.getItem(k); } catch { return null; } };
let picked = remembered('court') || COURTS[0].id;
let pickedTour = remembered('tour') || TOURS[0].id;
const go = stage => { intro.dataset.stage = stage; menuMusic(); }; // every stage change is a gesture, so it is also a safe place to (re)start the theme
// every stage change is a gesture, so it is also a safe place to (re)start the theme. The setup
// screen syncs its picker rows on entry: the identity screen writes the same picked* state, and
// rows that only ever toggle on their OWN taps would keep showing the boot-time selection.
const go = stage => { if (stage === 'setup') syncSetup(); intro.dataset.stage = stage; menuMusic(); };
// --- MODE selection in TWO STEPS (config.MENU + config.MODES): three posters on top, and the
// picked one deals its own second step underneath — classic deals the difficulty chips, zombie
@@ -236,6 +240,7 @@ const tourList = document.getElementById('tours');
for (const t of TOURS) {
const b = document.createElement('button');
b.className = t.id === pickedTour ? 'on' : '';
b.dataset.id = t.id; // syncSetup finds the row's buttons by id
// just the word now — the live player beside it is the portrait, and the tile art was a SECOND
// drawing of the same man that no colour on this screen could reach
b.textContent = t.name.replace(' SINGLES', '');
@@ -252,12 +257,15 @@ for (const t of TOURS) {
// way this can show a wrong colour is if the court would show it too.
// The sprite sheets load asynchronously and the menu deliberately parks the game loop, so the first
// draw can land before the art does: retry on a slow poll that stops itself the moment it succeeds.
const lookCv = document.getElementById('look');
const lookCv = document.getElementById('look'), pLookCv = document.getElementById('pLook');
let lookPoll = 0;
function refreshLook() {
const t = TOURS.find(x => x.id === pickedTour) || TOURS[0];
const ok = drawLook(lookCv, t, KITS.find(e => e.id === pickedKit) || KITS[0],
SKINS.find(e => e.id === pickedSkin) || SKINS[0]);
const k = KITS.find(e => e.id === pickedKit) || KITS[0];
const s = SKINS.find(e => e.id === pickedSkin) || SKINS[0];
// both live players — the setup screen's and the identity screen's — are the same man and must
// move together; drawing to whichever is hidden is a no-op-cheap blit
const ok = drawLook(lookCv, t, k, s) & drawLook(pLookCv, t, k, s);
clearTimeout(lookPoll);
if (!ok) lookPoll = setTimeout(refreshLook, 200); // sprites still coming; ask again shortly
}
@@ -271,6 +279,7 @@ const swatches = (rowId, nameId, table, keyName, get, set) => {
for (const e of table) {
const b = document.createElement('button');
b.className = e.id === get() ? 'on' : '';
b.dataset.id = e.id; // syncSetup finds the row's buttons by id
b.style.background = e.sw;
b.title = e.name; // desktop hover; the h3 label is what a phone reads
b.setAttribute('aria-label', e.name);
@@ -291,6 +300,57 @@ swatches('kitRow', 'kitName', KITS, 'kit', () => pickedKit, id => pickedKit = id
swatches('skinRow', 'skinName', SKINS, 'skin', () => pickedSkin, id => pickedSkin = id);
refreshLook();
// The setup screen's rows, brought up to date with picks made elsewhere (the identity screen).
// Selection state lives in picked*; the rows are just its picture and must be repainted on entry.
function syncSetup() {
for (const el of tourList.children) el.classList.toggle('on', el.dataset.id === pickedTour);
for (const [rowId, nameId, table, cur] of [['kitRow', 'kitName', KITS, pickedKit],
['skinRow', 'skinName', SKINS, pickedSkin]]) {
for (const el of document.getElementById(rowId).children)
el.classList.toggle('on', el.dataset.id === cur);
document.getElementById(nameId).textContent = (table.find(e => e.id === cur) || table[0]).name;
}
refreshLook(); summary();
}
// --- THE IDENTITY SCREEN (the leagues, phase 1). First boot only: name + tour + colours, then
// ENTER THE CLUB. It writes the SAME stores the setup screen reads (pickedTour/Kit/Skin, their
// localStorage keys, stats' name key), so the two screens cannot disagree — which is why the rows
// are REBUILT on every visit: their .on states have to reflect taps made on the other screen.
const pName = document.getElementById('pName');
function buildPlayer() {
pName.value = stats.savedName() || profile.zombieName();
const tRow = document.getElementById('pTours');
tRow.innerHTML = '';
for (const t of TOURS) {
const b = document.createElement('button');
b.className = t.id === pickedTour ? 'on' : '';
b.textContent = t.name.replace(' SINGLES', '');
b.addEventListener('click', () => {
pickedTour = t.id;
try { localStorage.setItem('tour', t.id); } catch {}
for (const el of tRow.children) el.classList.toggle('on', el === b);
refreshLook();
});
tRow.appendChild(b);
}
document.getElementById('pKitRow').innerHTML = '';
document.getElementById('pSkinRow').innerHTML = '';
swatches('pKitRow', 'pKitName', KITS, 'kit', () => pickedKit, id => pickedKit = id);
swatches('pSkinRow', 'pSkinName', SKINS, 'skin', () => pickedSkin, id => pickedSkin = id);
refreshLook();
}
document.getElementById('pDice').addEventListener('click', () => { pName.value = profile.zombieName(); });
document.getElementById('pBack').addEventListener('click', () => go('splash')); // setup stays open — it re-asks
document.getElementById('pGo').addEventListener('click', () => {
// an emptied field is not a refusal to have a name — the die decides, like the prefill did
stats.saveName(stats.tidyName(pName.value) || profile.zombieName());
try { localStorage.setItem('tour', pickedTour); } catch {}
profile.prof.setup = 1; profile.save(); profile.push();
buildTops(); go('mode');
});
pName.addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('pGo').click(); });
// --- court rows, built from the table.
// The two meters come out of the court's OWN bounce numbers rather than a pair of hand-written
// ratings, so a physics change moves the bars with it and the screen can never advertise a court
@@ -429,6 +489,11 @@ function summary() {
// has just ended and the count has moved.
function refreshSplash() {
document.getElementById('mKills').textContent = stats.total().kills.toLocaleString('en-GB') + ' KILLS';
// the level strip — the meta clock, re-read whenever the splash comes back because a run has
// just moved it (same rule as the kill count above)
const pr = profile.progress();
document.getElementById('xpLv').textContent = 'LEVEL ' + pr.level;
document.getElementById('xpFill').style.width = Math.round(pr.frac * 100) + '%';
summary();
}
@@ -467,8 +532,14 @@ const openStats = () => {
for (const el of document.getElementById('stModes').children) el.classList.toggle('on', el.dataset.m === stMode);
stats.drawStats(stPeriod, SPRV, stMode); go('stats');
};
// entering a question screen rebuilds it, so the LAST tag always names the run actually played
const goMode = () => { buildTops(); go('mode'); };
// entering a question screen rebuilds it, so the LAST tag always names the run actually played —
// and the FIRST visit ever detours through the identity screen (the leagues): the club wants a
// name before it deals a match. ENTER THE CLUB lands on the mode screen, so the detour costs the
// question it interrupted nothing.
const goMode = () => {
if (!profile.prof.setup) { buildPlayer(); go('player'); return; }
buildTops(); go('mode');
};
document.getElementById('mStart').addEventListener('click', goMode);
document.getElementById('mStats').addEventListener('click', openStats);
// the world board opens on the mode you last PICKED, like the stats screen does — that is the one
@@ -669,6 +740,9 @@ document.getElementById('stModes').addEventListener('click', e => {
stats.drawStats(stPeriod, SPRV, stMode);
});
refreshSplash();
// the profile mirror: adopt a server copy that is ahead (a wiped phone, a second device), then
// let the strip show whatever came back. Fire-and-forget — the menu never waits on the network.
profile.pull().then(refreshSplash);
// THE BOOT GATE. render.js fires all ~230 sprite requests at import; this only watches them and
// keeps the splash's buttons out of reach until the last one has landed and both faces are ready.
@@ -731,7 +805,11 @@ addEventListener('keydown', e => {
// one that actually fires on iOS, where beforeunload/unload do not. endRun is idempotent and
// no-ops on an empty run, so closing the tab at the menu writes nothing.
addEventListener('pagehide', () => {
profile.push(); // mirror whatever the run has earned — keepalive lets it survive the unload
if (G.mode !== 'splatter') return;
if (G.drill) return; // the drill banks nothing — its kills leak into the gore
// bag otherwise (killPlayer's drill guard covers death,
// not the tab-close path)
if (G.match && G.match.train) return; // the training court banks nothing, walked out on or not
// ...tagged with the mode that was being played, and carrying THAT mode's two headline numbers —
// see the record-shape note in stats.endRun. A match abandoned mid-rally is a match run.
@@ -749,6 +827,7 @@ const maybeRestart = () => {
const ready = G.done ? G.done.t > DONE_RESTART : G.mode === 'eaten' && G.eatenT > EATEN_RESTART;
if (G.modal || !ready) return;
restartRun();
profile.push(); // the run's xp is banked — mirror it while the moment is quiet
refreshSplash(); // the run just ended: the splash count has moved
intro.hidden = false;
quitChip.hidden = true; // the door out goes with the run it belonged to
@@ -773,6 +852,7 @@ document.getElementById('qNo').addEventListener('click', () => { qAsk.hidden = t
document.getElementById('qYes').addEventListener('click', () => {
qAsk.hidden = true; paused = false; quitChip.hidden = true;
restartRun();
profile.push(); // a quit banks no run, but the xp already awarded is real — mirror it
refreshSplash();
intro.hidden = false;
go('splash');
+146
View File
@@ -0,0 +1,146 @@
// profile.js — THE META-PROGRESSION PROFILE: xp, level, league, and its server mirror.
//
// The plan of record is PROJECT.md "THE LEAGUES". Phase 1 (this file): the clock ticks and is
// visible; nothing reads the level back into gameplay yet. The profile is the ONE persistent
// object the leagues hang off: xp/level (you), league + champion flag (the world), and the setup
// flag that gates the first-boot identity screen. Name, tour, kit and skin deliberately KEEP
// their existing homes (stats' NAME_KEY, the picker localStorage keys) — this file only mirrors
// them to the server so a wiped phone gets its whole identity back, not just its number.
//
// XP comes from ACTIVE PLAY EVENTS, never wall-clock (settled): losing pays too, training pays
// nothing. award() carries the no-stakes guard itself — drill, bench, test mode, the training
// court — so a new call site cannot forget it.
//
// The server mirror is a BACKUP, not an account: localStorage is the authority while it exists,
// and pull() adopts the mirror only when it is AHEAD on xp (a wiped phone, a second device).
// Same trust stance as the world board: the client is public JS, the id is the only claim.
import { API, UI } from './config.js';
import { G } from './state.js';
import { myId, savedName, saveName } from './stats.js';
const KEY = 'splatterProfile';
function load() {
try {
const p = JSON.parse(localStorage.getItem(KEY) || 'null');
if (p && typeof p === 'object') {
return { v: 1, xp: Math.max(0, p.xp | 0), lg: Math.min(5, Math.max(1, p.lg | 0 || 1)),
ch: p.ch ? 1 : 0, setup: p.setup ? 1 : 0 };
}
} catch {}
return { v: 1, xp: 0, lg: 1, ch: 0, setup: 0 };
}
export const prof = load();
export function save() {
try { localStorage.setItem(KEY, JSON.stringify(prof)); } catch {}
}
// ---------------------------------------------------------------- the curve ----
// Fast early, slow late (settled): the first levels land inside the first run — the hook — and
// the late ones each take sessions. Cumulative xp to REACH level l; one constant and one power,
// because a table would be twenty numbers saying the same thing.
export const LEVEL_CAP = 50;
export const xpFor = l => Math.round(80 * Math.pow(Math.max(0, l - 1), 1.8));
export function levelAt(xp) {
let l = 1;
while (l < LEVEL_CAP && xp >= xpFor(l + 1)) l++;
return l;
}
// { level, into, need, frac } — everything a bar needs, one call. frac is 1 at the cap.
export function progress(xp = prof.xp) {
const level = levelAt(xp);
if (level >= LEVEL_CAP) return { level, into: 0, need: 0, frac: 1 };
const a = xpFor(level), b = xpFor(level + 1);
return { level, into: xp - a, need: b - a, frac: (xp - a) / (b - a) };
}
// ---------------------------------------------------------------- awards ----
// What one event pays. Sized so a decent run lands ~1-2k xp: kills are plentiful and cheap,
// points are rarer, a game is the headline. Rebalancing is this table and nothing else.
export const XP = { kill: 2, point: 8, game: 40, duelWin: 60, round: 150, cup: 400 };
// The run's own delta, for the death screen ("+340 XP · LEVEL 7 → 8"). Module-local like the
// stats bag, and for the same reason: G is restored from PRISTINE by restartRun and the summary
// has to survive exactly that. beginRunXp() is called beside stats.beginRun().
let runXp = 0, runFrom = levelAt(prof.xp);
export function beginRunXp() { runXp = 0; runFrom = levelAt(prof.xp); }
export const runDelta = () => ({ xp: runXp, from: runFrom, to: levelAt(prof.xp) });
// ONE guard for every no-stakes mode, in the shared function — a call site cannot forget it.
const muted = () => G.drill || G.bench || G.testMode || (G.match && G.match.train);
export function award(kind, n = 1) {
if (muted()) return 0;
const amt = (XP[kind] || 0) * n;
if (!amt) return 0;
const before = levelAt(prof.xp);
prof.xp += amt; runXp += amt;
save();
const after = levelAt(prof.xp);
// the level-up announces as a CARD, never a modal — it can land on a live court (popup policy)
if (after > before && !G.modal)
G.card = { title: 'LEVEL ' + after, sub1: null, color: UI.pick, t: 2.4 };
return amt;
}
// ---------------------------------------------------------------- the mirror ----
// POST is fire-and-forget with keepalive so the pagehide path can use the same call. The server
// refuses to go backward on xp (see server.py), so a stale device cannot clobber the investment.
export async function push() {
try {
const id = myId();
if (!id) return; // private mode: no claim, no mirror
const grab = k => { try { return localStorage.getItem(k) || ''; } catch { return ''; } };
await fetch(API + 'profile', { method: 'POST', keepalive: true,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, p: { ...prof, n: savedName(),
tour: grab('tour'), kit: grab('kit'), skin: grab('skin') } }) });
} catch {}
}
// Adopt the mirror when it is ahead — and bring the identity back with it, but never overwrite
// an identity this device already has (localStorage is the authority while it exists).
export async function pull() {
try {
const id = myId();
if (!id) return;
const r = await fetch(API + 'profile?id=' + id, { cache: 'no-store' });
if (!r.ok) return;
const p = (await r.json()).p;
if (!p) return;
if ((p.xp | 0) > prof.xp) {
prof.xp = p.xp | 0;
prof.lg = Math.min(5, Math.max(prof.lg, p.lg | 0 || 1));
prof.ch = prof.ch || (p.ch ? 1 : 0);
prof.setup = prof.setup || (p.setup ? 1 : 0);
runFrom = levelAt(prof.xp); // the adopted xp is history, not this run's delta
save();
}
if (!savedName() && p.n) saveName(p.n);
for (const k of ['tour', 'kit', 'skin'])
try { if (!localStorage.getItem(k) && p[k]) localStorage.setItem(k, p[k]); } catch {}
} catch {}
}
// ---------------------------------------------------------------- zombie names ----
// The first-boot prefill and the dice button. Two lists beat one, and the 12-char board limit
// (server NAME_MAX) is enforced by re-rolling, not by truncating a name into gibberish.
const NAME_A = ['ACE', 'DEUCE', 'SMASH', 'VOLLEY', 'GRIM', 'ROT', 'BONE', 'CRYPT', 'GRAVE',
'GHOUL', 'FESTER', 'MOLDY', 'PUTRID', 'RIGOR', 'TOMB', 'SLICE', 'LOB',
'MAGGOT', 'MORBID', 'RANCID', 'SOGGY', 'LIMP'];
const NAME_B = ['FANG', 'DECAY', 'MARROW', 'CADAVER', 'SLUDGE', 'GRISTLE', 'KNUCKLE', 'SOCKET',
'GIBLET', 'WORM', 'SHANK', 'SPLEEN', 'MOLAR', 'STUMP', 'CRAWLER', 'RATTLE',
'DRIBBLE', 'WHIFF'];
export function zombieName() {
for (let i = 0; i < 40; i++) {
const n = NAME_A[(Math.random() * NAME_A.length) | 0] + ' '
+ NAME_B[(Math.random() * NAME_B.length) | 0];
if (n.length <= 12) return n;
}
return 'BONE FANG';
}
+15 -1
View File
@@ -7,6 +7,7 @@ import { RAISERS, FRAMES } from './crowd.js';
import { predictLanding, previewServe, slotId, slotStock, serveApexY, rallyFeel, swayOf } from './gameplay.js';
import { drillQueue, drillLegal, drillMult } from './drill.js';
import { zombies, gore, piles, parts, pellets, extras, shots, pops } from './zombies.js';
import { runDelta, progress } from './profile.js';
// ---------------------------------------------------------------- palette ----
// tennis palette -> drained splatter palette; the transition lerps between them.
@@ -2905,10 +2906,23 @@ export function render() {
: G.classic ? 'LONGEST MATCH ' + G.best.games + ' GAMES'
: 'BEST ' + G.best.games + ' GAMES · ' + G.best.sets + ' SETS', W / 2, H / 2 + 4, UI.dim);
}
// THE RUN'S XP — the moment meta-progress absorbs the permadeath (the leagues, phase 1).
// Muted modes never award, so a training rep or a drill shows nothing here by construction.
const xd = runDelta();
if (xd.xp > 0) {
const pr = progress();
ctx.font = F(8);
shadowText('+' + xd.xp + ' XP' + (xd.to > xd.from ? ' · LEVEL ' + xd.from + ' → ' + xd.to
: ' · LEVEL ' + pr.level),
W / 2, H / 2 + 18, xd.to > xd.from ? UI.pick : UI.cream);
const bw = 120, bx = Math.round(W / 2 - bw / 2), by = Math.round(H / 2 + 30);
ctx.fillStyle = 'rgba(255,255,255,0.14)'; ctx.fillRect(bx, by, bw, 4);
ctx.fillStyle = UI.warm; ctx.fillRect(bx, by, Math.round(bw * pr.frac), 4);
}
// gated well past the animation so a panic tap can't skip it (main.js gates the restart the same)
if (dead ? G.eatenT > EATEN_RESTART : G.done.t > DONE_RESTART) {
ctx.font = F(8);
shadowText(IS_TOUCH ? 'TAP TO PLAY AGAIN' : 'PRESS ANY KEY TO PLAY AGAIN', W / 2, H / 2 + 32, UI.warm);
shadowText(IS_TOUCH ? 'TAP TO PLAY AGAIN' : 'PRESS ANY KEY TO PLAY AGAIN', W / 2, H / 2 + 44, UI.warm);
}
ctx.restore();
}
+1 -1
View File
@@ -182,7 +182,7 @@ export const savedName = () => { try { return localStorage.getItem(NAME_KEY) ||
// 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() {
export function myId() { // exported 2026-08-29: the profile mirror keys on the same claim
try {
let v = localStorage.getItem(ID_KEY);
if (!v) { v = Array.from(crypto.getRandomValues(new Uint8Array(12)))
+4 -1
View File
@@ -11,6 +11,7 @@ import { drillKill, drillCaught } from './drill.js';
import { splatPoint, cheer, spentBall, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.js';
import { resolveInterlude, endBossFight } from './beats.js'; // runtime-only cycle, like the gameplay one
import { tally, multiKill, endRun, MODE_MATCH } from './stats.js';
import { award } from './profile.js';
export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'}
export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer
@@ -498,10 +499,12 @@ function killZombie(i, quiet) {
// the one that has been killing you.
// A MATCH OPPONENT FILES UNDER WHO IT IS, not under the generic body it wears: the screen is a
// list of who you have felled, and 'r' is a body, not a name. zb.foe is set by matchOpponent.
if (zb.variant !== 'o')
if (zb.variant !== 'o') {
tally(zb.foe ? zb.foe.key
: zb.variant === 'B' ? 'B' + Math.max(0, BOSS_ART.indexOf(zb.art))
: zb.variant, CAUSE);
award('kill'); // the leagues' clock, at the same single funnel — award() mutes no-stakes modes
}
zombies.splice(i, 1);
// THE DRILL RESOLVES HERE — after the splice, and WITHOUT returning. Both halves are load-bearing:
// splice first, because drillKill can draw a new combination on the spot (a double fault
+71
View File
@@ -103,6 +103,8 @@ const { TUNE, bossArt, COURT, CONTACT, racketStage, matchWound } = cfg;
const statsMod = await import('./src/stats.js');
const audio = await import('./src/audio.js');
const { hapticTest } = await import('./src/haptics.js');
const prof = await import('./src/profile.js');
const { advancePoint } = await import('./src/gameplay.js');
const { read: readStats, check: statsCheck } = statsMod;
const DT = 1 / 60;
@@ -2122,5 +2124,74 @@ test('THE READ: a habit is learned by repetition and a break wrong-foots the rac
assert.ok(TUNE.readHabit >= 2 && TUNE.readHabit <= 6, 'a habit is learnable inside one game');
});
// ---- THE LEAGUES, phase 1: the profile clock -------------------------------
// The plan of record is PROJECT.md "THE LEAGUES". These pin the three promises phase 1 makes:
// the curve is fast-early/slow-late and capped, the award funnel pays every mode and mutes every
// no-stakes mode, and the run delta survives nothing it should not.
test('PROFILE: the level curve is monotonic, fast early, capped', () => {
let prev = -1;
for (let l = 1; l <= prof.LEVEL_CAP; l++) {
const x = prof.xpFor(l);
assert.ok(x > prev, 'xp thresholds strictly climb');
prev = x;
}
assert.ok(prof.xpFor(2) <= 120, 'level 2 lands inside the first minutes — the hook');
assert.ok(prof.xpFor(prof.LEVEL_CAP) > 50000, 'the cap is a long way off — levelling is not easy');
assert.equal(prof.levelAt(0), 1, 'everyone starts at level 1');
assert.equal(prof.levelAt(prof.xpFor(7)), 7, 'levelAt inverts xpFor on the threshold');
assert.equal(prof.progress(prof.xpFor(prof.LEVEL_CAP) + 999).frac, 1, 'the cap holds at a full bar');
// steps GROW: each level costs more than the one before (fast early IS slow late)
const step = l => prof.xpFor(l + 1) - prof.xpFor(l);
assert.ok(step(40) > step(10) && step(10) > step(2), 'the ladder steepens as it climbs');
});
test('PROFILE: award pays through the one point funnel, and mutes every no-stakes mode', () => {
const xp0 = prof.prof.xp;
advancePoint('P');
assert.equal(prof.prof.xp, xp0 + prof.XP.point, 'a rally point pays through advancePoint');
advancePoint('O');
assert.equal(prof.prof.xp, xp0 + prof.XP.point, 'the opponent\'s point pays you nothing');
// a won game pays the game on top of its final point (3 more points win it from 1-0)
score.pts = [3, 0];
advancePoint('P');
assert.equal(prof.prof.xp, xp0 + 2 * prof.XP.point + prof.XP.game, 'the game lands with its last point');
// every no-stakes mode is muted INSIDE award — a call site cannot forget the guard
for (const flag of ['drill', 'bench', 'testMode']) {
const before = prof.prof.xp;
G[flag] = true;
assert.equal(prof.award('kill'), 0, flag + ' pays nothing');
assert.equal(prof.prof.xp, before, flag + ' moved no xp');
G[flag] = false;
}
G.match = { train: true };
assert.equal(prof.award('kill'), 0, 'the training court pays nothing');
G.match = null;
assert.ok(prof.award('kill') > 0, '...and a real kill pays again');
});
test('PROFILE: the run delta survives the death screen and resets with the next run', () => {
prof.beginRunXp();
const from = prof.runDelta().from;
prof.award('kill'); prof.award('point');
const d = prof.runDelta();
assert.equal(d.xp, prof.XP.kill + prof.XP.point, 'the delta is the run\'s own earnings');
assert.equal(d.from, from, 'the starting level was recorded at the run start');
restartRun(); // the tap off the death screen
assert.equal(prof.runDelta().xp, 0, 'the next run opens with a clean delta');
assert.ok(prof.prof.xp > 0, '...while the banked xp survives, which is the whole point');
});
test('PROFILE: zombie names fit the board and its charset, and the profile round-trips', () => {
for (let k = 0; k < 60; k++) {
const n = prof.zombieName();
assert.ok(n.length >= 3 && n.length <= 12, 'a name fits the 12-char board limit: ' + n);
assert.equal(n, statsMod.tidyName(n), 'a rolled name is already board-legal: ' + n);
}
prof.prof.lg = 3; prof.save();
assert.ok(prof.prof.xp >= 0 && prof.prof.lg === 3, 'the profile holds what was written');
prof.prof.lg = 1; prof.save();
});
console.log(`\n${passed} tests passed`);
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report