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

+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;