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:
1 parent
e96b4374d8
commit
80eb60d181
8 files changed
+226
-40
No files matched your search
+7
-3
@@ -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
@@ -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
@@ -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
@@ -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';
|
||||
}
|
||||
Reference in new issue
Block a user