The cup board: the league ranks, the level breaks the tie

Oliver wanted to rank the level a league was taken at. The challenge held: xp
comes only from playing, so a board that rewards a low level rewards not
playing — it would pay you to avoid the three rooms the season leans on. It
also inverts every other number in the game, freezes after five entries, and
makes the player who played more look worse.

So the league ranks and the level decides between everyone who got equally far,
where "lower is better" reads naturally. BOARDS.u carries subLow; server
rank_key is the other half, and the same key decides whether a submission beats
the row already standing. The level is written once at the promotion — before
the league moves, because it is the level that WON the cup — mirrored like the
rest of the profile, and inscribed under the shield it belongs to. Earned
shields smoulder while they are on screen: five CSS embers each, no loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Fable 5 committed 2026-08-31 15:51:38 +02:00
1 parent e96b4374d8
commit 80eb60d181
8 files changed
+226 -40

No files matched your search

+47
View File
@@ -643,6 +643,53 @@ through.
Open for the phone: whether the fixture button should ever go to the setup screen (it does not
now), and whether the promotion's WALK ON should enter the cup instead of the clubhouse.
## Session log: THE STATS STREAMLINE AND THE CUP BOARD (2026-08-31, STAGING ONLY)
Oliver: "stats und board — es ist total verwirrend, ich habe keine ahnung was dort angezeigt wird...
ich habe die vermutung, dass hier noch viele kpis erfasst werden, die in alten spielmodi bedeutung
hatten". He was right, and the count was worse than the feeling: the screen had THREE mode tabs
times THREE period tabs, and the same three boxes meant something different in every one of the
nine states (KILLS/MULTI-KILLS/DEATHS · FELLED/DEEPEST/RUNS · PLAYED/WON/LOST). The card repeated
three of those numbers summed across modes, so the same word meant "this mode" in one place and
"all modes" in the other — and "BEST RUN" on the card counted GAMES while "BEST" on the board
counted KILLS.
**What went, and why:**
- **MULTI-KILLS** — a vanity number on no board, from the gore era.
- **DEATHS** — every gore and climb run ends in one, so it was RUNS again in a second box.
- **The period tabs** — LAST RUN is the summary you just tapped away; 7 DAYS is a fitness metric in
a permadeath arcade game. `read()` keeps the parameter; the UI does not.
- **The mode tabs on BODY COUNT** — one career, four numbers that never change meaning: BODIES
FELLED, BEST GORE RUN, DEEPEST CLIMB, RUNS. Then the roster (the collection, which is what the
screen is actually good at) and the rack.
- **The CLASSIC board** — classic tennis is OUT OF COMPETITION by its own room's rule since the
text reduction, so ranking it contradicted the wall. Its records still bank locally; they just no
longer submit, and a classic best no longer opens the name prompt.
**THE CUP BOARD** (Oliver: "cup board finde ich das wichtigste"), and the one real design argument
of the session. He wanted to rank THE LEVEL YOU TOOK THE LEAGUE AT. Challenged, and the challenge
held: xp comes only from playing, so a board that rewards a low level rewards NOT PLAYING — it
would pay you to avoid gore, the climb and training, the three rooms the season leans on. Three
smaller objections: it inverts the reading direction of every other number in the game, it can be
set exactly five times in a career and then freezes, and it makes the player who played MORE look
worse.
Settled instead: **the league ranks, the level breaks the tie.** `LEAGUE 4 · AT 22` — the canonical
achievement sorts, and his number decides between everyone who got equally far, where "lower is
better" reads naturally. Playing more costs you the tie-break at worst, never the place.
- `BOARDS.u` (`MODE_CUP`) carries `subLow: true`; `server.rank_key()` is the other half — the ONE
board whose second column sorts ascending, and the "is this submission better" test uses the same
key, so a better row is a higher league or the same league at a lower level.
- `profile.at` (league -> level) is written ONCE, at the promotion, before `prof.lg` moves — the
level that WON the cup, not the one you walk on with. Mirrored server-side like `cups`.
- A promotion posts itself (`main.postCup`): no name prompt in the way, no personal-best gate.
- The same number is the INSCRIPTION under each earned shield on the card (`AT 14`) — where a
figure set once in a career belongs. Earned shields smoulder now: five CSS embers each, random
lane and delay, no game loop involved.
The card also stopped repeating the ledger (it carries the CAREER: league, cups won, best cup) and
its crest moved beside the league line instead of pushing it down.
## Session log: THE MENU TEXT REDUCTION — the design handoff, built (2026-08-31, STAGING ONLY)
A handoff from a design session (its folder is on Oliver's disk, git-ignored: the prototype ships a
+50 -12
View File
@@ -266,9 +266,15 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
/* four numbers read better two-up than four-across on a phone — at four the labels wrap and
the row becomes a wall of two-line captions */
.figs.pair { display: grid; grid-template-columns: 1fr 1fr; }
.fig { flex: 1; min-width: 0; padding: 7px 6px; text-align: center; }
.figs.pair .fig:nth-child(2n) { border-left: 2px solid var(--line); }
.figs.pair .fig:nth-child(n+3) { border-top: 2px solid var(--line); }
.fig { flex: 1; min-width: 0; padding: 11px 8px 12px; text-align: center; }
.fig + .fig { border-left: 2px solid var(--line); }
.fig b { display: block; font: 400 9px Silkscreen, monospace; color: var(--dim); letter-spacing: 1px; }
/* THE NUMBER NEVER MOVES (2026-08-31, Oliver: "manchmal sind die überschriften ein- manchmal
zweizeilig, das drückt die zahl runter"). The label gets two lines of room whether it needs
them or not, so every figure in a row sits on the same baseline. */
.fig b { display: flex; align-items: flex-start; justify-content: center; min-height: 22px;
font: 400 9px/1.25 Silkscreen, monospace; color: var(--dim); letter-spacing: 1px; }
.fig i { display: block; font: 700 18px Silkscreen, monospace; color: var(--acid);
font-style: normal; letter-spacing: 1px; }
.fig.bad i { color: var(--bad); }
@@ -344,18 +350,48 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
#cdTop { display: flex; gap: 14px; align-items: flex-start; margin-top: 14px; }
#cdLook { position: static; display: block; flex: none; margin: 0; width: auto; height: 200px;
touch-action: auto; image-rendering: pixelated; }
#cdMeta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
#cdBadge { width: 56px; image-rendering: pixelated; }
#cdLeague { font: 700 10px Silkscreen, monospace; text-decoration: none; letter-spacing: 1.5px; }
#cdLevel { font: 400 30px Bangers, cursive; color: var(--cream); letter-spacing: 1.5px; }
#cdMeta { flex: 1; min-width: 0; display: grid; gap: 8px 11px; align-items: center;
grid-template-columns: auto 1fr;
grid-template-areas: "crest league" "crest level" "bar bar" "xp xp" "career career"; }
/* THE CREST STANDS BESIDE THE LEAGUE (2026-08-31, Oliver: on its own row it pushed the text
down), so the two things that say WHERE YOU ARE share a line instead of stacking. */
#cdBadge { grid-area: crest; width: 56px; image-rendering: pixelated; align-self: center; }
#cdLeague { grid-area: league; align-self: end; font: 700 10px/1.5 Silkscreen, monospace;
text-decoration: none; letter-spacing: 1.5px; }
#cdLeague i { display: block; font-style: normal; font-size: 9px; opacity: 0.85; }
#cdBar { grid-area: bar; }
#cdXp { grid-area: xp; }
#cdCareer { grid-area: career; }
#cdLevel { grid-area: level; align-self: start; font: 400 30px Bangers, cursive;
color: var(--cream); letter-spacing: 1.5px; }
#cdXp { display: block; margin-top: 5px; font: 400 9px Silkscreen, monospace; color: var(--dim);
text-decoration: none; letter-spacing: 1px; }
/* THE HONOURS ROW: a shield per league, and under an earned one the INSCRIPTION — the level it
was taken at (the cup board's tie-break, in the one place a once-in-a-career figure belongs). */
#cdBadges { display: flex; gap: 10px; align-items: flex-end; }
#cdBadges .badge { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column;
align-items: center; gap: 4px; }
/* ...and an earned shield SMOULDERS: two-pixel embers drifting off it, each on its own delay and
lane (main.embers). Pure CSS — the menu parks the game loop and this has no business waking it. */
#cdBadges .badge em { position: absolute; bottom: 22px; width: 3px; height: 3px; background: #f2cf6a;
box-shadow: 0 0 4px 1px rgba(232,178,58,0.55);
opacity: 0; pointer-events: none; animation-name: ember;
animation-timing-function: linear; animation-iteration-count: infinite; }
@keyframes ember {
0% { transform: translate(0, 0) scale(1); opacity: 0; }
18% { opacity: 0.85; }
60% { transform: translate(2px, -16px) scale(0.8); opacity: 0.5; }
100% { transform: translate(-1px, -30px) scale(0.4); opacity: 0; } }
@media (prefers-reduced-motion: reduce) { #cdBadges .badge em { display: none; } }
#cdBadges img { height: 46px; image-rendering: pixelated; }
#cdBadges .badge u { font: 700 7px Silkscreen, monospace; color: var(--acid); letter-spacing: 0.5px;
text-decoration: none; white-space: nowrap; }
#cdBadges .badge.dark img { filter: grayscale(1) brightness(0.3); }
#cdBadges .badge.dark u { color: #3f4d45; }
#cdBadges img.dark { filter: grayscale(1) brightness(0.3); }
/* earned badges BREATHE — a subtle gold halo, CSS-only (the menu parks the game loop, CSS
animations do not care). The dark slots stay dead, which is the point of them. */
#cdBadges img:not(.dark), #cdBadge {
#cdBadges .badge:not(.dark) img, #cdBadges img:not(.dark), #cdBadge {
animation: badgeGlow 2.6s ease-in-out infinite; }
@keyframes badgeGlow {
0%, 100% { filter: drop-shadow(0 0 2px rgba(232, 178, 58, 0.25)); }
@@ -687,9 +723,10 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
.zrow.boss .pb s { background: var(--bad); }
.zb i { grid-column: 1 / -1; margin-top: 4px; font: 400 9px Silkscreen, monospace;
font-style: normal; color: var(--dim); letter-spacing: 1px; }
#stW { display: flex; flex-wrap: wrap; gap: 6px; }
.wtile { flex: 1 1 62px; max-width: 84px; padding: 8px 2px; border: 2px solid var(--line);
background: var(--panel); }
/* a GRID, not a wrapping flex row: `flex: 1 1 62px` gave the last row's tiles the whole width
to share, so a shelf of nine came out in three different sizes (Oliver) */
#stW { display: grid; grid-template-columns: repeat(auto-fill, minmax(66px, 1fr)); gap: 6px; }
.wtile { padding: 8px 2px; border: 2px solid var(--line); background: var(--panel); }
.wtile img { width: 34px; height: 34px; object-fit: contain; display: block; margin: 0 auto 2px;
image-rendering: pixelated; }
.wtile b { display: block; font: 400 11px Silkscreen, monospace; color: var(--acid); }
@@ -996,7 +1033,8 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
<!-- TWO boards, not three (2026-08-31): classic tennis is out of competition by design now
— no league, no record, no stakes — so ranking it contradicted the room it lives in. -->
<div class="tabs" id="rkModes">
<button class="on" data-m="g">SPLATTER</button>
<button class="on" data-m="u">THE CUP</button>
<button data-m="g">SPLATTER</button>
<button data-m="z">THE CLIMB</button>
</div>
<h3 id="rkHdr">BEST SINGLE RUN<span>KILLS · GAMES</span></h3>
@@ -1038,6 +1076,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div>
</div>
</div>
<script type="module" src="src/main.js?v=342"></script>
<script type="module" src="src/main.js?v=343"></script>
</body>
</html>
+31 -4
View File
@@ -34,7 +34,19 @@ NAME_MAX = 12
# care what the numbers MEAN (see stats.BOARDS on the client); it sorts them within a mode and
# nothing crosses. Rows written before the split carry no mode and ARE gore rows, which is why 'g'
# is the default everywhere and no migration is needed.
MODES = ('g', 'z', 'c')
MODES = ('g', 'z', 'c', 'u')
# THE CUP BOARD ('u', 2026-08-31) is the one board whose second column is better LOW: it ranks the
# LEAGUE a player took and breaks ties on the LEVEL they took it at. The client says the same thing
# in stats.BOARDS (subLow) — this is the other half of it, and the only place the sort differs.
SUB_LOW = ('u',)
def rank_key(m):
"""Board order: score DESC always, then the sub column, then age."""
low = m in SUB_LOW
return lambda r: (-r.get('k', 0), r.get('g', 0) if low else -r.get('g', 0), r.get('t', 0))
lock = threading.Lock()
# ---- THE PROFILE MIRROR (2026-08-29, the leagues). localStorage alone dies with cleared browser
@@ -86,6 +98,17 @@ def clean_profile(payload):
v = 0
if v > 0:
cups[str(lg)] = min(9, v)
# ...and the INSCRIPTION: league -> the level you took its cup at (2026-08-31, the cup board)
at = {}
raw_at = p.get('at')
if isinstance(raw_at, dict):
for lg in range(1, 6):
try:
v = int(raw_at.get(str(lg), raw_at.get(lg, 0)))
except (TypeError, ValueError):
v = 0
if v > 0:
at[str(lg)] = min(50, v)
try:
out = {'v': 1,
'xp': max(0, min(int(p.get('xp', 0)), 1_000_000_000)),
@@ -94,6 +117,7 @@ def clean_profile(payload):
'setup': 1 if p.get('setup') else 0,
'cups': cups,
'out': max(0, min(int(p.get('out', 0)), 99)),
'at': at,
'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):
@@ -373,7 +397,10 @@ class Handler(SimpleHTTPRequestHandler):
# per-device — the same player on a second phone starts from zero and posts worse runs,
# so a weaker submission has to be dropped HERE rather than merely not sorted to the top.
mine = [r for r in rows if r.get('n') == row['n'] and mode_of(r) == row['m']]
if row['k'] > max((r.get('k', 0) for r in mine), default=-1):
# "better" is the BOARD's own order, not a bare >: on the cup board the same league at
# a lower level beats the row already standing there.
key = rank_key(row['m'])
if not mine or key(row)[:2] < min(key(r)[:2] for r in mine):
rows = [r for r in rows
if not (r.get('n') == row['n'] and mode_of(r) == row['m'])]
rows.append(row)
@@ -381,12 +408,12 @@ class Handler(SimpleHTTPRequestHandler):
kept = []
for m in MODES:
rs = [r for r in rows if mode_of(r) == m]
rs.sort(key=lambda r: (-r.get('k', 0), -r.get('g', 0), r.get('t', 0)))
rs.sort(key=rank_key(m))
kept += rs[:KEEP]
rows = kept
save(rows)
board = [r for r in rows if mode_of(r) == row['m']]
board.sort(key=lambda r: (-r.get('k', 0), -r.get('g', 0), r.get('t', 0)))
board.sort(key=rank_key(row['m']))
return self.reply(200, {'ok': True, 'm': row['m'],
'rank': rank_of(board, row['n']), 'scores': public(board)})
+7 -3
View File
@@ -9,7 +9,7 @@
import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage, leagueOf, cupRoundsFor, cupFixture, foeLevel } from './config.js';
import { endRun, MODE_MATCH } from './stats.js';
import { award, prof, save as profSave, push as profPush, setPromo, levelAt, recordCup } from './profile.js';
import { award, prof, save as profSave, push as profPush, setPromo, levelAt, recordCup, recordCupWin } 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';
@@ -920,8 +920,12 @@ function cupWon() {
// main.maybeRestart plays once the summary is tapped away. Winning league 5 crowns the profile
// instead (prof.ch) — the summary below already says CHAMPION OF THE DEAD CLUB.
if (!d.train && G.league === prof.lg) {
if (prof.lg < 5) { prof.lg++; setPromo({ to: prof.lg }); }
else if (!prof.ch) { prof.ch = 1; setPromo({ champion: true }); }
// THE INSCRIPTION (the cup board): the level you were when you took it, written before the
// promotion moves anything — it is the level that WON the cup, not the one you walk on with.
const at = levelAt(prof.xp);
recordCupWin(G.league, at);
if (prof.lg < 5) { prof.lg++; setPromo({ to: prof.lg, took: G.league, at }); }
else if (!prof.ch) { prof.ch = 1; setPromo({ champion: true, took: G.league, at }); }
profSave(); profPush();
}
if (!d.train) recordCup(G.league, d.cup.rounds.length); // the full record: you took the final
+39 -13
View File
@@ -598,6 +598,15 @@ const countUp = (write, target, dur = 700) => {
if (p < 1) cardTimers.push(setTimeout(tick, 30));
})();
};
// EMBERS off an earned shield (2026-08-31, Oliver: "particles die random nach oben wegfliegen, wie
// leichtes feuer"). CSS keyframes, not a loop: the menu deliberately parks the game loop, and six
// two-pixel sparks per badge have no business waking it. The randomness is baked into each spark's
// own delay, duration and lane at build time, so no two shields burn in step.
const embers = won => !won ? '' : Array.from({ length: 5 }, () =>
`<em style="left:${(14 + Math.random() * 62).toFixed(0)}%;` +
`animation-delay:${(Math.random() * 2.6).toFixed(2)}s;` +
`animation-duration:${(1.9 + Math.random() * 1.5).toFixed(2)}s"></em>`).join('');
function buildCard() {
for (const t of cardTimers) clearTimeout(t);
cardTimers = [];
@@ -605,10 +614,12 @@ function buildCard() {
document.getElementById('cdName').textContent = stats.savedName() || 'THE WALK-ON';
document.getElementById('cdBadge').src = `assets/badge_l${lg.id}.png?v=${SPRV}`;
const el = document.getElementById('cdLeague');
el.textContent = `LEAGUE ${lg.id} OF ${LEAGUES.length} — ${lg.name}`;
// the crest stands beside this line now, and the column is narrow: two short lines beat one
// that wraps three times (the league's NAME is the second, in its own colour)
el.innerHTML = `LEAGUE ${lg.id} OF ${LEAGUES.length}<i>${lg.name}</i>`;
el.style.color = lg.color;
document.getElementById('cdLevel').textContent =
'LEVEL ' + pr.level + ' OF ' + profile.LEVEL_CAP;
// ...and the cap belongs to the bar under it, which already counts toward the next level
document.getElementById('cdLevel').textContent = 'LEVEL ' + pr.level;
document.getElementById('cdFrom').textContent = pr.level;
document.getElementById('cdTo').textContent = pr.need ? pr.level + 1 : 'MAX';
// both bars sweep from empty to their mark (the 0.7s eased transition in the CSS)
@@ -628,18 +639,24 @@ function buildCard() {
const row = document.getElementById('cdBadges');
row.innerHTML = '';
for (const l of LEAGUES) {
const img = document.createElement('img');
img.src = `assets/badge_l${l.id}.png?v=${SPRV}`;
img.title = l.name;
// a league's badge is EARNED by finishing it — winning its cup. Being past it means exactly
// that; league five has no "past", so its shield lights with the champion's crown.
img.className = l.id < profile.prof.lg || (l.id === LEAGUES.length && profile.prof.ch) ? '' : 'dark';
row.appendChild(img);
const won = l.id < profile.prof.lg || (l.id === LEAGUES.length && profile.prof.ch);
const at = profile.cupAt(l.id);
const cell = document.createElement('div');
cell.className = 'badge' + (won ? '' : ' dark');
// THE INSCRIPTION (2026-08-31, the cup board): a trophy says when it was won. Here that is the
// LEVEL it was won at — the number the cup board breaks its ties on, in the one place where a
// figure set once and never again belongs.
// an unearned slot says nothing: the shield's own art already says which league it is
cell.innerHTML = `<img src="assets/badge_l${l.id}.png?v=${SPRV}" alt="" title="${l.name}">`
+ `<u>${won ? (at ? 'AT ' + at : 'WON') : '—'}</u>` + embers(won);
row.appendChild(cell);
}
const champ = document.createElement('img');
champ.src = `assets/badge_champ.png?v=${SPRV}`;
champ.title = 'CHAMPION OF THE DEAD CLUB';
champ.className = profile.prof.ch ? '' : 'dark';
const champ = document.createElement('div');
champ.className = 'badge' + (profile.prof.ch ? '' : ' dark');
champ.innerHTML = `<img src="assets/badge_champ.png?v=${SPRV}" alt="" title="CHAMPION OF THE DEAD CLUB">`
+ `<u>${profile.prof.ch ? 'CHAMPION' : '—'}</u>` + embers(profile.prof.ch);
row.appendChild(champ);
const g = stats.read('all', undefined, stats.MODE_GORE),
z = stats.read('all', undefined, stats.MODE_MATCH),
@@ -697,6 +714,15 @@ function buildPromo(pr, run) {
? `THE TITLE IS YOURS TO HOLD<u>EVERY CUP FROM HERE IS A DEFENCE</u>`
: `NEXT: ${fx.title}<u>${fx.line}</u>`;
}
// THE CUP BOARD (2026-08-31): a promotion is the row, and it posts itself. There is no name
// prompt in the way — you have had a name since the identity screen — and no personal-best gate:
// the server keeps one row per name per board and the league you just took either beats what
// stands there or does not (rank_key, server.py).
function postCup(pr) {
const took = pr.champion ? LEAGUES.length : pr.took;
if (!took || !pr.at || !stats.savedName()) return;
stats.submit(stats.savedName(), took, pr.at, stats.MODE_CUP, profile.prof.lg).catch(() => {});
}
document.getElementById('prGo').addEventListener('click', () => {
const run = promoRun; promoRun = null;
if (run && run.newKillBest && run.score > 0) askName(run);
@@ -1091,7 +1117,7 @@ const maybeRestart = e => {
// THE PROMOTION plays first (the leagues): the ceremony the cup win parked, then the name
// prompt it may also have earned (prGo hands the run on), then the title.
const promo = profile.takePromo();
if (promo) { buildPromo(promo, run); go('promo'); return; }
if (promo) { postCup(promo); buildPromo(promo, run); go('promo'); return; }
// ...on the BOARD's number, not on kills: a classic match has none, so the gore-shaped test here
// silently barred two of the three modes from ever reaching the prompt
// ...on a board that still EXISTS: classic tennis left the world board with the stats streamline
+19 -4
View File
@@ -27,10 +27,10 @@ const KEY = 'splatterProfile';
// That is what turns a lost cup from a null event into a line on the board — and what lets the
// training court offer the one that beat you. Bounded on load like everything else that comes
// back off a device.
const cleanCups = c => {
const cleanCups = (c, cap = 9) => {
const out = {};
if (c && typeof c === 'object')
for (let l = 1; l <= 5; l++) { const v = c[l] | 0; if (v > 0) out[l] = Math.min(9, v); }
for (let l = 1; l <= 5; l++) { const v = c[l] | 0; if (v > 0) out[l] = Math.min(cap, v); }
return out;
};
@@ -40,10 +40,11 @@ function load() {
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,
cups: cleanCups(p.cups), out: Math.max(0, Math.min(99, p.out | 0)) };
cups: cleanCups(p.cups), at: cleanCups(p.at, 50),
out: Math.max(0, Math.min(99, p.out | 0)) };
}
} catch {}
return { v: 1, xp: 0, lg: 1, ch: 0, setup: 0, cups: {}, out: 0 };
return { v: 1, xp: 0, lg: 1, ch: 0, setup: 0, cups: {}, at: {}, out: 0 };
}
export const prof = load();
@@ -131,6 +132,16 @@ export function bonus() {
// panel and the training court. A record only ever moves FORWARD — an early exit after a deep run
// does not erase the deep run, it just is not news.
export const cupBest = (lg = prof.lg) => prof.cups[lg] | 0;
// THE INSCRIPTION (2026-08-31, the cup board): the level you were when you took a league's cup.
// It is what the cup board breaks its ties on and what a badge on the card says under it — the
// same cup taken with fewer buffs is the better win. Written once, at the promotion, and never
// overwritten: a league is taken once.
export const cupAt = lg => prof.at[lg] | 0;
export function recordCupWin(lg, level) {
if (!(lg >= 1 && lg <= 5) || !(level > 0) || prof.at[lg]) return;
prof.at[lg] = Math.min(LEVEL_CAP, level);
save();
}
export function recordCup(lg, reached, rung = 0) {
if (!(lg >= 1 && lg <= 5) || reached < 1) return;
if (reached > cupBest(lg)) prof.cups[lg] = Math.min(9, reached);
@@ -185,6 +196,10 @@ export async function pull() {
if (v > cupBest(l)) prof.cups[l] = Math.min(9, v);
}
prof.out = prof.out || Math.max(0, Math.min(99, p.out | 0));
for (let l = 1; l <= 5; l++) { // the inscription, never overwritten
const v = p.at && p.at[l] | 0;
if (v > 0 && !prof.at[l]) prof.at[l] = Math.min(LEVEL_CAP, v);
}
runFrom = levelAt(prof.xp); // the adopted xp is history, not this run's delta
save();
}
+16 -3
View File
@@ -92,7 +92,7 @@ export function multiKill() { run.mk++; }
// opponents felled and has exactly one kill per opponent, by definition, always credited to 'bomb'
// (matchGameWon's explode). Un-tagged records predate the split and ARE gore runs, which is why the
// default is 'g' everywhere and no migration is needed.
export const MODE_GORE = 'g', MODE_MATCH = 'z', MODE_CLASSIC = 'c';
export const MODE_GORE = 'g', MODE_MATCH = 'z', MODE_CLASSIC = 'c', MODE_CUP = 'u';
const modeOf = r => r.m || MODE_GORE;
// ---- WHAT A RUN IS WORTH, per mode. The world board used to be the gore mode's alone, which made
@@ -114,6 +114,14 @@ export const BOARDS = {
unit: 'FELLED', subUnit: 'G', head: 'FELLED · GAMES' },
[MODE_CLASSIC]: { name: 'CLASSIC', score: r => r.s || 0, sub: r => r.g || 0,
unit: 'GAMES WON', subUnit: 'PLAYED', head: 'WON · PLAYED' },
// THE CUP BOARD (2026-08-31) is the only one that does not rank a RUN: its rows are promotions.
// The LEAGUE you took ranks — higher is better, the canonical achievement — and the LEVEL you
// took it at breaks the tie, where LOWER is better: the same cup won with fewer buffs is the
// better win. That inversion is deliberate and lives in exactly one place, this row and the
// server's sort beside it. Ranking the LEVEL itself was considered and rejected: xp comes from
// playing, so a board that rewards a low level rewards not playing.
[MODE_CUP]: { name: 'THE CUP', score: r => r.k || 0, sub: r => r.g || 0,
unit: 'LEAGUE', subUnit: 'AT LEVEL', head: 'LEAGUE · AT LEVEL', subLow: true },
};
const boardOf = m => BOARDS[m] || BOARDS[MODE_GORE];
@@ -327,7 +335,8 @@ export async function drawRanks(rows, mode = MODE_GORE) {
// expected, a league-2 upset is visible. Old rows carry none and show none.
+ (r.lg >= 1 && r.lg <= 5 ? `<img class="rkbadge" src="assets/badge_l${r.lg | 0}.png" alt="">` : '')
+ `<span>${esc(r.n)}</span>`
+ `<i>${n(r.k || 0)} · ${r.g || 0}</i></div>`).join('');
+ `<i>${mode === MODE_CUP ? 'LEAGUE ' + (r.k || 0) + ' · AT ' + (r.g || 0)
: n(r.k || 0) + ' · ' + (r.g || 0)}</i></div>`).join('');
}
// ------------------------------------------------------------------ check ----
@@ -377,6 +386,10 @@ export function check() {
eq(B.z.score(fx[3]), 4, 'zombie tennis ranks opponents felled');
eq(B.z.sub(fx[3]), 9, '...with the games it took as the tiebreak');
eq(B.c.score({ g: 15, s: 12 }), 12, 'classic ranks the games you took off the CPU');
eq(Object.keys(B).sort().join(''), 'cgz', 'three boards, no more and no fewer');
// the CUP board ranks the league and breaks ties on the level it was taken at, LOW first
eq(B.u.score({ k: 4, g: 22 }), 4, 'the cup ranks the league you took');
eq(B.u.sub({ k: 4, g: 22 }), 22, '...and carries the level you took it at');
eq(B.u.subLow, true, '...where lower is the better tie-break, the one inversion in the game');
eq(Object.keys(B).sort().join(''), 'cguz', 'four boards, no more and no fewer');
return 'ok';
}
+17 -1
View File
@@ -117,7 +117,7 @@ function freshSplatter() {
prof.prof.lg = 1; prof.prof.ch = 0; prof.prof.xp = 0; prof.takePromo();
// THE SEASON: the cup record is written by a real cup ending, so it leaks between tests exactly
// the way the promotion did — the training court reads `out`, and a stale one reorders its list
prof.prof.cups = {}; prof.prof.out = 0;
prof.prof.cups = {}; prof.prof.at = {}; prof.prof.out = 0;
G.mode = 'splatter';
G.everTransitioned = true;
setupPoint();
@@ -1292,6 +1292,22 @@ test('THE SEASON: a cup exit is recorded, forward only, and names who put you ou
assert.equal(prof.prof.out, out, 'a training rep is a rep, not a cup attempt');
});
test('THE CUP BOARD: the league ranks, the level it was taken at breaks the tie', () => {
// the inscription is written at the promotion and never again — a league is taken once
prof.prof.lg = 2; prof.prof.xp = prof.xpFor(9);
prof.recordCupWin(1, prof.levelAt(prof.prof.xp));
assert.equal(prof.cupAt(1), 9, 'the level that won the cup is the one inscribed');
prof.recordCupWin(1, 3);
assert.equal(prof.cupAt(1), 9, '...and a later write never overwrites it');
assert.equal(prof.cupAt(2), 0, 'a league not yet taken carries no inscription');
// the board itself: league is the score, the level the tie-break, and it is the ONE board whose
// second column is better LOW (server.py's rank_key is the other half of this)
const B = statsMod.BOARDS[statsMod.MODE_CUP];
assert.equal(B.score({ k: 4, g: 22 }), 4, 'the league ranks');
assert.equal(B.sub({ k: 4, g: 22 }), 22, 'the level rides along');
assert.ok(B.subLow, 'and lower is better on it');
});
test('THE CUP CLIMBS: rounds run easy to hard, deterministic, the final the top of the band', () => {
// the shuffle retired with the draw screen (Oliver: "warum sollten wir noch mischen?") — a bad
// roll paired the strongest challenger with the shortest round, inverting the tuned curve