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