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:
1 parent
bc10216d0b
commit
9133c0d351
12 files changed
+507
-19
No files matched your search
@@ -1,6 +1,9 @@
|
||||
.env
|
||||
scores.json
|
||||
scores.json.tmp
|
||||
profiles.json
|
||||
profiles.json.tmp
|
||||
__pycache__/
|
||||
assets/_gen/
|
||||
assets/_refcheck/
|
||||
assets/__pycache__/
|
||||
|
||||
+35
-1
@@ -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
@@ -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">‹</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">🎲</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>
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user