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 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. 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 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), the ladder position spread over the profile's 50 levels), so you can feel whether you are that
so you can feel whether you are that body's equal yet. His ask, verbatim. 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 - **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 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 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 # OPEN — leftovers, in rough priority order
Nothing here is broken; these are known gaps and unverified risks. 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> </div>
</div> </div>
<script type="module" src="src/main.js?v=347"></script> <script type="module" src="src/main.js?v=348"></script>
</body> </body>
</html> </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; 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). // 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. // 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 // Netplay stays a league-1 world. Training still stamps league 1 because it promotes nobody,
// shipped game (train still picks any met rung, but at the shipped pace). // but its selected opponent supplies the real territory pace through gameplay.leaguePace.
G.league = G.net || train ? 1 : prof.lg; 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, // 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. // 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; const cup = G.match.cup;
// THE WALK-ON wears its own zoned layout (render.drawFoeModal — Oliver: the generic line // 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 // 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 const sub = cup ? [cup.rounds[cup.round].name + (cup.rounds[cup.round].need > 1
? ' · FIRST TO ' + cup.rounds[cup.round].need + ' GAMES' : '')] ? ' · 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 // 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) // 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'] : []; : G.match.again ? ['NOBODY STRONGER IS LEFT', 'THE CUP IS THE WAY UP'] : [];
const lvl = foeLevel(G.match.opp), mine = levelAt(prof.xp); 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, showModal({ title: zb.foe.name, color: UI.hot,
body: [zb.foe.line, 'LEVEL ' + lvl + ' — YOURS: ' + mine].concat(sub), body: [zb.foe.line, 'LEVEL ' + lvl + ' — YOURS: ' + mine].concat(sub),
foe: { head: 'z_' + zb.fam + 'head', name: zb.foe.name, line: zb.foe.line, 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) // ...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; 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 // 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 // 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 // IS the shipped game (factor 1.0, the calibration rule) and league 5
// plays ~24% faster. The one knob for "das Spiel wird schneller". // 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 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 // the broken recovery (the player never walked back to centre, so every
// wide return looked like opponent strength) — with real footwork restored // 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 // 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 // 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]; 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, // the level of a ladder position (the walk-on card's "am I its peer yet?" line, 2026-08-29):
// 2026-08-29): the ladder spread over the profile's 50 levels. Display only — nothing scales // the ladder spread over the profile's 50 levels. gameplay.peerK reads it for the cup/training
// off it. 49 = profile.LEVEL_CAP - 1 (config cannot import profile; the cycle runs the other way). // 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); 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 // 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. // 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 { 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 { audio, pock, swish, whoosh, applause, voice, voiceSeq, sfx, aww, zombieMusic, zombieDuck, menuDuck, splat, shriek, boom } from './audio.js';
import { haptic } from './haptics.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 { W } from './render.js'; // runtime-only cycle (render imports gameplay), same pattern as input/zombies
import { updateDrill } from './drill.js'; import { updateDrill } from './drill.js';
import { endRun, beginRun, MODE_CLASSIC } from './stats.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 { 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 { 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 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 // 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 // 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. // 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; // THE PEER GIFT turns the walk-on's displayed level into a real promise. It exists only in the
export const winLevel = () => G.match && !G.net ? bonus().window : 1; // 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 // 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). // 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) 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. // 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 // 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 // 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 // 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 // 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. // 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 // 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 // one quarter. award() carries the no-stakes guard itself — drill, bench and test mode — so a new
// court — so a new call site cannot forget it. // call site cannot forget it.
// //
// The server mirror is a BACKUP, not an account: localStorage is the authority while it exists, // 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). // 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 rows = [];
const push = r => { rows.push(r); return r; }; const push = r => { rows.push(r); return r; };
if (m.foe) { if (m.foe) {
push({ kind: 'split', h: 26 }); push({ kind: 'split', h: m.foe.peer ? 38 : 26 });
const sub = []; const sub = [];
for (const l of m.foe.sub || []) sub.push(...wrapText(l, F(8, 0), inner)); 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 }); 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); shadowText('LEVEL ' + f.lvl, inX, y + 9, f.lvl > f.mine ? col : UI.pick);
ctx.textAlign = 'right'; ctx.textAlign = 'right';
shadowText('YOURS ' + f.mine, x + L.boxW - pad, y + 9, UI.dim); 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'; ctx.textAlign = 'left';
} }
y += r.h + MODAL_GAP; y += r.h + MODAL_GAP;
+22 -1
View File
@@ -89,7 +89,7 @@ globalThis.getComputedStyle = () => ({ paddingTop: '0' });
// ---- game modules (real code) ---------------------------------------------- // ---- game modules (real code) ----------------------------------------------
const state = await import('./src/state.js'); const state = await import('./src/state.js');
const { G, P, O, score, ball, setupPoint } = state; 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 { 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'); 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 // 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; 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`); console.log(`\n${passed} tests passed`);
process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report