From bfc70d7388d11ec280c84cd34066206303fe98e3 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Thu, 3 Sep 2026 16:41:02 +0200 Subject: [PATCH] Make the opponent level matter in the cup --- PROJECT.md | 26 ++++++++++++++++++++++++-- index.html | 2 +- src/beats.js | 12 ++++++++---- src/config.js | 11 ++++++++--- src/gameplay.js | 13 +++++++++---- src/profile.js | 6 +++--- src/render.js | 6 +++++- test.js | 23 ++++++++++++++++++++++- 8 files changed, 80 insertions(+), 19 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index ae467fd..6a9c01e 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -881,8 +881,8 @@ under discussion): below league 3 there are no lasers anywhere, ever; rockets need league 4; the gore director's tuned income curve is untouched for licensed players. Tin test rewritten to licence semantics. - **THE PEER LINE**: every walk-on modal now carries "LEVEL 34 — YOURS: 20" (config.foeLevel — - the ladder position spread over the profile's 50 levels, display only, nothing scales off it), - so you can feel whether you are that body's equal yet. His ask, verbatim. + the ladder position spread over the profile's 50 levels), so you can feel whether you are that + body's equal yet. It began as display; the 2026-09-03 readiness pass below made it mechanical. - **THE STEWARD GOT HIS VEST BACK.** The tennis family was rolled off z_d1 (which wears the orange hi-vis) but the FAMS words said "dark uniform jacket" — and the words won. The words name the vest now ("on him in EVERY single pose"); family re-rolled through @@ -4843,6 +4843,28 @@ and the fallback above hid it completely. Every scripted edit in this repo shoul --- +## Session log: THE PEER GIFT — the opponent level becomes real (2026-09-03) + +Oliver reached league 4 at level 25 against opponents rated around 40 and asked the decisive +question: would grinding to level 40 materially improve his chance? It would not — the existing +sqrt curve had already paid most of its +15% legs / +20% window by level 25, leaving only 2.6% +legs and 6 ms of ordinary contact window between levels 25 and 40. Worse, `foeLevel` was explicitly +display-only, so the walk-on implied a matchup measure the game did not honour. + +**THE PEER GIFT** (`gameplay.peerK`) makes that visible comparison real without changing any +opponent: in the canonical cup and the exact training rep, fifteen levels below the current body +is factor 0 and parity is factor 1, clamped at both ends. It adds at most +8% legs and +12% contact +window on top of the permanent level bonus. There is no underlevel penalty, no opponent scaling, +and no extra growth above parity. Arcade remains the stable whole-club challenge; classic, +multiplayer and gore remain untouched. Against the league-4 final (rated 41), level 40 now has +about 10.3% more effective legs and 27 ms more ordinary contact window than level 25 — meaningful, +not an automatic win. + +The walk-on gives the comparison its own line (`16 LEVELS ABOVE YOU` / `ALMOST EVEN` / `EVENLY +MATCHED` / `YOU HAVE THE EDGE`) beneath the equal-size level pair. Three dials live with the other +match tuning (`peerGap`, `peerRun`, `peerWindow`); no profile state was added. Suite 129 pins the +zero/parity bounds, material level-25-to-parity gain, cup/training scope, and arcade/netplay exits. + # OPEN — leftovers, in rough priority order Nothing here is broken; these are known gaps and unverified risks. diff --git a/index.html b/index.html index 02979e2..3c07486 100644 --- a/index.html +++ b/index.html @@ -1082,6 +1082,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/beats.js b/src/beats.js index 7846387..86d168d 100644 --- a/src/beats.js +++ b/src/beats.js @@ -367,8 +367,8 @@ export function startMatch(cup = false, draw = null, train = 0) { if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench || G.net) return false; // THE LEAGUES: the profile's league is the world (band, pace tier, court — see PROJECT.md). // Stamped onto G so gameplay's leaguePace() and the dials below never read the profile module. - // Netplay and training stay league-1 worlds: a human opponent and a practice rep both play the - // shipped game (train still picks any met rung, but at the shipped pace). + // Netplay stays a league-1 world. Training still stamps league 1 because it promotes nobody, + // but its selected opponent supplies the real territory pace through gameplay.leaguePace. G.league = G.net || train ? 1 : prof.lg; // THE CLIMB: the arcade opens at the very BOTTOM of the ladder and runs the whole earned club, // clamping at your league's top (matchGameWon) — the marathon whose length the promotions buy. @@ -669,17 +669,21 @@ function matchOpponent() { const cup = G.match.cup; // THE WALK-ON wears its own zoned layout (render.drawFoeModal — Oliver: the generic line // stack was "a wall of text"). `foe` drives the drawing; title/body still carry the same - // words for the suite and any legacy reader. The level pair is display only. + // words for the suite and any legacy reader. The level pair is also the cup's readiness measure: + // gameplay.peerK turns closing this gap into extra legs and timing, without weakening the body. const sub = cup ? [cup.rounds[cup.round].name + (cup.rounds[cup.round].need > 1 ? ' · FIRST TO ' + cup.rounds[cup.round].need + ' GAMES' : '')] // the top of the band walking BACK on is the arcade's natural "what now?" moment — the // intro answers it in place (THE LEAGUES: the cup is the only promotion) : G.match.again ? ['NOBODY STRONGER IS LEFT', 'THE CUP IS THE WAY UP'] : []; const lvl = foeLevel(G.match.opp), mine = levelAt(prof.xp); + const gap = lvl - mine; + const peer = gap > 5 ? gap + ' LEVELS ABOVE YOU' : gap > 0 ? 'ALMOST EVEN' + : gap === 0 ? 'EVENLY MATCHED' : 'YOU HAVE THE EDGE'; showModal({ title: zb.foe.name, color: UI.hot, body: [zb.foe.line, 'LEVEL ' + lvl + ' — YOURS: ' + mine].concat(sub), foe: { head: 'z_' + zb.fam + 'head', name: zb.foe.name, line: zb.foe.line, - lvl, mine, sub } }); + lvl, mine, peer, sub } }); // ...and a fresh body has read NOTHING yet: the habit tracker starts clean (see THE READ, config) G.match.laneRun = 0; G.match.lastLane = undefined; G.match.surprised = G.match.expected = false; // a FLAT pool per body: with regicide gone the body is pure scoreboard (matchPoint takes a diff --git a/src/config.js b/src/config.js index 248fd26..630033e 100644 --- a/src/config.js +++ b/src/config.js @@ -556,6 +556,10 @@ export const TUNE = { // ball pace on BOTH rackets and the opponent's legs together, so league 1 // IS the shipped game (factor 1.0, the calibration rule) and league 5 // plays ~24% faster. The one knob for "das Spiel wird schneller". + peerGap: 15, // THE CUP'S SOFT WALL: this many levels below its opponent earns none of + // the peer gift; reaching parity earns all of it. Never a penalty. + peerRun: 0.08, // parity adds 8% legs on top of the permanent level bonus + peerWindow: 0.12, // ...and 12% contact window. Skill still wins; grinding opens the margin matchSpeedBase: 1.25, // racketAI speed multiplier for opponent ONE. 1.4 was calibrated against // the broken recovery (the player never walked back to centre, so every // wide return looked like opponent strength) — with real footwork restored @@ -933,9 +937,10 @@ export const leagueOf = lg => LEAGUES[Math.min(LEAGUES.length, Math.max(1, lg | // which league a LADDER POSITION belongs to — THE CLIMB's tempo reads this (the world speeds up // as the run crosses each band border), and it is what makes the club read as five territories export const leagueOfRung = n => LEAGUES.find(l => n <= l.band[1]) || LEAGUES[LEAGUES.length - 1]; -// the DISPLAYED level of a ladder position (the walk-on card's "am I its peer yet?" line, -// 2026-08-29): the ladder spread over the profile's 50 levels. Display only — nothing scales -// off it. 49 = profile.LEVEL_CAP - 1 (config cannot import profile; the cycle runs the other way). +// the level of a ladder position (the walk-on card's "am I its peer yet?" line, 2026-08-29): +// the ladder spread over the profile's 50 levels. gameplay.peerK reads it for the cup/training +// readiness gift; the opponent itself never scales with the player's level. 49 = +// profile.LEVEL_CAP - 1 (config cannot import profile; the cycle runs the other way). export const foeLevel = n => Math.round(1 + (n - 1) / (LADDER - 1) * 49); // The tournament, PER LEAGUE. Same shape CUP_ROUNDS always had (name/band/need) — round one is a diff --git a/src/gameplay.js b/src/gameplay.js index a96e522..bf171b4 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -1,6 +1,6 @@ // Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update. -import { TUNE, UI, bossArt, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage, matchWound, LADDER, LEAGUES, leagueOfRung, TIN_LEAGUE, TIN_DOUBLE_LEAGUE } from './config.js'; +import { TUNE, UI, bossArt, APPLAUSE_T, EATEN_BURST, EATEN_SUMMARY, COURT, CONTACT, VOLLEY, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE, zombieH, Z_CHEST , WEAPONS, RACK_MAX , BARREL_WEAPONS, racketStage, matchWound, LADDER, LEAGUES, leagueOfRung, foeLevel, TIN_LEAGUE, TIN_DOUBLE_LEAGUE } from './config.js'; import { P, O, ball, score, crowd, G, court, tour, other, deuceSide, setupPoint, sendBoy, resetState } from './state.js'; import { audio, pock, swish, whoosh, applause, voice, voiceSeq, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js'; import { haptic } from './haptics.js'; @@ -8,7 +8,7 @@ import { axisX, axisZ, takeFlickAim, touchPX, resetInput } from './input.js'; import { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies import { updateDrill } from './drill.js'; import { endRun, beginRun, MODE_CLASSIC } from './stats.js'; -import { award, beginRunXp, prof, bonus } from './profile.js'; +import { award, beginRunXp, prof, bonus, levelAt } from './profile.js'; import { updateZombies, updateParts, spawnZombie, spawnRacketZombie, spawnMachine, spawnChapterBoss, zombies, gore, explode, meleeSwipe, extras, parts, pellets, shots, playerGore, eatenBleed, resetZombies , firePellet , spawnBarrel , fireBeam , pops, popText } from './zombies.js'; import { proceedAfterClear, startDuel, startHorde, warnHorde, resolveInterlude, watchdog, updateBench, benchKey, tickMatchFall, placeReceiver } from './beats.js'; // runtime-only cycle: beats calls back into cheer/aside/endBurst/deliverTin/showModal/logSwing import { netSendShot, netSendToss, netTick, netJudging, netSendPoint, netSendFault, netClose } from './net.js'; // runtime-only cycle: net calls back into pointTo/fault/tossBall/showModal @@ -1319,8 +1319,13 @@ export const rallyFeel = () => !!(G.match || G.classic); // with the level — LEAGUE modes only (the climb, the cup, the training court). Classic keeps its // own difficulty picker, netplay is a fair human match, gore is untouched (its trio lives in // profile.bonus too, read elsewhere). Level 1 is factor 1: the shipped baseline is the floor. -export const legLevel = () => G.match && !G.net ? bonus().run : 1; -export const winLevel = () => G.match && !G.net ? bonus().window : 1; +// THE PEER GIFT turns the walk-on's displayed level into a real promise. It exists only in the +// canonical cup and its exact training rep: arcade remains a stable whole-club challenge. Fifteen +// levels down is today's wall, parity gets the full gift, and being underlevelled is never a malus. +export const peerK = () => G.match && !G.net && (G.match.cup || G.match.train) + ? Math.max(0, Math.min(1, 1 - (foeLevel(G.match.opp) - levelAt(prof.xp)) / TUNE.peerGap)) : 0; +export const legLevel = () => G.match && !G.net ? bonus().run * (1 + TUNE.peerRun * peerK()) : 1; +export const winLevel = () => G.match && !G.net ? bonus().window * (1 + TUNE.peerWindow * peerK()) : 1; // legLevel rides INSIDE assistK, so the mover's legs and intercept's reachability can never // disagree about what those legs can get to (the rzChaseSpeed rule, player-side). export const assistK = () => (rallyFeel() ? Math.max(TUNE.autoAssist, TUNE.matchAssist) : TUNE.autoAssist) diff --git a/src/profile.js b/src/profile.js index f9c3173..40b7645 100644 --- a/src/profile.js +++ b/src/profile.js @@ -1,15 +1,15 @@ // profile.js — THE META-PROGRESSION PROFILE: xp, level, league, and its server mirror. // // The plan of record is PROJECT.md "THE LEAGUES". Phase 1 (this file): the clock ticks and is -// visible; nothing reads the level back into gameplay yet. The profile is the ONE persistent +// visible; gameplay reads its bounded bonuses back through bonus()/levelAt(). The profile is the ONE persistent // object the leagues hang off: xp/level (you), league + champion flag (the world), and the setup // flag that gates the first-boot identity screen. Name, tour, kit and skin deliberately KEEP // their existing homes (stats' NAME_KEY, the picker localStorage keys) — this file only mirrors // them to the server so a wiped phone gets its whole identity back, not just its number. // // XP comes from ACTIVE PLAY EVENTS, never wall-clock (settled): losing pays too, training pays -// nothing. award() carries the no-stakes guard itself — drill, bench, test mode, the training -// court — so a new call site cannot forget it. +// one quarter. award() carries the no-stakes guard itself — drill, bench and test mode — so a new +// call site cannot forget it. // // The server mirror is a BACKUP, not an account: localStorage is the authority while it exists, // and pull() adopts the mirror only when it is AHEAD on xp (a wiped phone, a second device). diff --git a/src/render.js b/src/render.js index 01807ac..288a74b 100644 --- a/src/render.js +++ b/src/render.js @@ -2368,7 +2368,7 @@ function modalRows(m, inner, skipBody) { const rows = []; const push = r => { rows.push(r); return r; }; if (m.foe) { - push({ kind: 'split', h: 26 }); + push({ kind: 'split', h: m.foe.peer ? 38 : 26 }); const sub = []; for (const l of m.foe.sub || []) sub.push(...wrapText(l, F(8, 0), inner)); if (sub.length) push({ kind: 'text', lines: sub, dim: true, h: sub.length * 12 + 2 }); @@ -2494,6 +2494,10 @@ function drawModal(m) { shadowText('LEVEL ' + f.lvl, inX, y + 9, f.lvl > f.mine ? col : UI.pick); ctx.textAlign = 'right'; shadowText('YOURS ' + f.mine, x + L.boxW - pad, y + 9, UI.dim); + if (f.peer) { + ctx.font = F(8, 700); ctx.textAlign = 'left'; + shadowText(f.peer, inX, y + 25, f.lvl > f.mine ? col : UI.pick); + } ctx.textAlign = 'left'; } y += r.h + MODAL_GAP; diff --git a/test.js b/test.js index 2d92eba..6692e3b 100644 --- a/test.js +++ b/test.js @@ -89,7 +89,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' }); // ---- game modules (real code) ---------------------------------------------- const state = await import('./src/state.js'); const { G, P, O, score, ball, setupPoint } = state; -const { update, forceZombies, startGore, splatPoint, restartRun, leaguePace, duelSkill, tinLadder, deliverTin, legLevel: _legLevel, winLevel: _winLevel, assistK: _assistK, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt } = await import('./src/gameplay.js'); +const { update, forceZombies, startGore, splatPoint, restartRun, leaguePace, duelSkill, tinLadder, deliverTin, peerK: _peerK, legLevel: _legLevel, winLevel: _winLevel, assistK: _assistK, flickTarget, flatCount, swayOf, keyPressed, classicGame, serveApexY, assistK, rallyFeel, barrelGap, machCap, hitServe, cheer, inCourt } = await import('./src/gameplay.js'); const { startDuel, resolveInterlude, startBossFight, endBossFight, startDrill: _startDrill, endDrill, startMatch: _startMatch, endMatch, FALL_LEN, startNetVs, endNetVs } = await import('./src/beats.js'); const { mirror, netMsg, netTest, netTick } = await import('./src/net.js'); // the drill's first-sight modal freezes the world (that is what modals do) — dismiss it the way a @@ -2439,5 +2439,26 @@ test('THE LEVEL IS YOU, in the league: legs and window grow with xp — baseline prof.prof.xp = 0; }); +test('THE PEER GIFT: cup and its training rep ease from fifteen levels down to parity', () => { + const peerK = _peerK, legLevel = _legLevel, winLevel = _winLevel; + G.match = { opp: 15, cup: {} }; // displayed level 41 + prof.prof.xp = prof.xpFor(25); + assert.equal(peerK(), 0, 'sixteen levels down gets no gift and no penalty'); + const legs25 = legLevel(), win25 = winLevel(); + prof.prof.xp = prof.xpFor(36); + assert.ok(peerK() > 0.6 && peerK() < 1, 'closing most of the gap earns most of the gift'); + prof.prof.xp = prof.xpFor(41); + assert.equal(peerK(), 1, 'parity earns the full gift'); + assert.ok(legLevel() / legs25 > 1.1, 'level 41 has materially more reachability than level 25'); + assert.ok(TUNE.swingWindow * (winLevel() - win25) > 0.025, 'the normal contact window gains over 25ms'); + G.match = { opp: 15 }; // arcade: fixed comparison + assert.equal(peerK(), 0, 'arcade does not soften with opponent parity'); + G.match = { opp: 15, train: true }; + assert.equal(peerK(), 1, 'the exact training rep feels like the cup match'); + G.net = { on: true }; + assert.equal(peerK(), 0, 'netplay never reads profile advantage'); + G.net = null; G.match = null; prof.prof.xp = 0; +}); + console.log(`\n${passed} tests passed`); process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report