Make the opponent level matter in the cup

This commit is contained in:
wuwi committed 2026-09-03 16:41:02 +02:00
1 parent 058636fae3
commit bfc70d7388
8 files changed
+80 -19

No files matched your search

+24 -2
View File
@@ -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.
+1 -1
View File
@@ -1082,6 +1082,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat
</div>
</div>
</div>
<script type="module" src="src/main.js?v=347"></script>
<script type="module" src="src/main.js?v=348"></script>
</body>
</html>
+8 -4
View File
@@ -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
+8 -3
View File
@@ -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
+9 -4
View File
@@ -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)
+3 -3
View File
@@ -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).
+5 -1
View File
@@ -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;
+22 -1
View File
@@ -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