From 80eb60d1813f875765e8c1738adf338434f76b49 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Mon, 31 Aug 2026 15:51:38 +0200 Subject: [PATCH] The cup board: the league ranks, the level breaks the tie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PROJECT.md | 47 ++++++++++++++++++++++++++++++++++++++ index.html | 62 ++++++++++++++++++++++++++++++++++++++++---------- server.py | 35 ++++++++++++++++++++++++---- src/beats.js | 10 +++++--- src/main.js | 52 +++++++++++++++++++++++++++++++----------- src/profile.js | 23 +++++++++++++++---- src/stats.js | 19 +++++++++++++--- test.js | 18 ++++++++++++++- 8 files changed, 226 insertions(+), 40 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index c8d28d5..20eacfb 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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 diff --git a/index.html b/index.html index 98a9d45..5512d40 100644 --- a/index.html +++ b/index.html @@ -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
- + +

BEST SINGLE RUNKILLS · GAMES

@@ -1038,6 +1076,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/server.py b/server.py index d3735a3..9192ece 100644 --- a/server.py +++ b/server.py @@ -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)}) diff --git a/src/beats.js b/src/beats.js index 54a4b6d..7846387 100644 --- a/src/beats.js +++ b/src/beats.js @@ -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 diff --git a/src/main.js b/src/main.js index 2a73a3e..c757128 100644 --- a/src/main.js +++ b/src/main.js @@ -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 }, () => + ``).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}${lg.name}`; 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 = `` + + `${won ? (at ? 'AT ' + at : 'WON') : '—'}` + 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 = `` + + `${profile.prof.ch ? 'CHAMPION' : '—'}` + 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 HOLDEVERY CUP FROM HERE IS A DEFENCE` : `NEXT: ${fx.title}${fx.line}`; } +// 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 diff --git a/src/profile.js b/src/profile.js index 647c0f5..f9c3173 100644 --- a/src/profile.js +++ b/src/profile.js @@ -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(); } diff --git a/src/stats.js b/src/stats.js index 439e56d..ef96c8c 100644 --- a/src/stats.js +++ b/src/stats.js @@ -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 ? `` : '') + `${esc(r.n)}` - + `${n(r.k || 0)} · ${r.g || 0}`).join(''); + + `${mode === MODE_CUP ? 'LEAGUE ' + (r.k || 0) + ' · AT ' + (r.g || 0) + : n(r.k || 0) + ' · ' + (r.g || 0)}`).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'; } diff --git a/test.js b/test.js index bfdde07..2d92eba 100644 --- a/test.js +++ b/test.js @@ -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