Make the opponent level matter in the cup
This commit is contained in:
1 parent
058636fae3
commit
bfc70d7388
8 files changed
+80
-19
No files matched your search
+24
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user