diff --git a/PROJECT.md b/PROJECT.md index e12eda9..a7db82c 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -491,6 +491,33 @@ zombie (now an occasional opponent, `TUNE.racketEvery`, not the every-game fixtu `RETURNED`, `DOUBLE KILL`) is a footnote to something you already watched happen. Sharing the loud FACE with the milestone cards is right; sharing their SIZE made every footnote read as a milestone. +## Session log: THE CLIMB — the arcade gets its identity back (2026-08-29, same day, STAGING ONLY) + +Oliver: "arcade verhält sich genau so [wie der cup].. die grenzen werden unscharf." Right — the +phase-2 band clamp had reduced the arcade to the cup's own 3-4 bodies, loose. The two modes have +sharp identities again: + +- **THE ARCADE IS THE CLIMB**: it opens at the very BOTTOM of the ladder (opp 1) and runs the + whole EARNED club, clamping at your league's top (the clamp, its felled counter and its + "NOBODY STRONGER / THE CUP IS THE WAY UP" walk-on lesson all stay). The marathon's length + grows with every promotion — league 2 climbs 8 bodies, league 5 all 18 — and the FELLED board + means something again. Poster sub: "THE WHOLE CLUB, BOTTOM UP · BLOOD ONLY". +- **The tempo hangs on the OPPONENT'S territory now** (`config.leagueOfRung`, read by + leaguePace): the low ladder plays its own shipped tempo and the world pulls away at each band + border — the climb makes the five leagues legible as five territories. Cup unchanged in effect + (its pool IS your band); the training court now practises a body at the tempo you would really + meet it at (a deliberate revision of phase 2's flat league-1 training world). Netplay exempt. +- **The climb picks its court freely** (among the unlocked): the CUP is the league fixture with + the fixed home venue, the climb is the exhibition. start()'s forcing, the setup's single-row + venue face and the summary's LEAGUE suffix are all cup-only again. +- **The climb's setup face** (replaces the league panel there): THE CLIMB writ large, "THE CLUB + FROM THE BOTTOM — N STAND · BEST RUN: M FELLED" (stats' own longest), no roster wall — the + training picker already shows the club face-up. +- **"RUNG" is gone from the game's copy** (Oliver: too exotic) — the training tiles say "No. 7", + the club roster number. It only ever appeared there; everywhere else the word lives in comments. +- Tests reworked: the climb walkthrough (opens at 1, clamps at 8 for league 2, felled honest) and + the pace test (flat inside a band, steps at each border, netplay/gore exempt). Suite 124. + ## Session log: THE LEAGUES — the setup grows a league face (2026-08-29, same day, STAGING ONLY) Two more from Oliver's screenshots: diff --git a/index.html b/index.html index 954e835..01d40c9 100644 --- a/index.html +++ b/index.html @@ -920,6 +920,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/beats.js b/src/beats.js index e19015b..a3b4538 100644 --- a/src/beats.js +++ b/src/beats.js @@ -368,10 +368,10 @@ export function startMatch(cup = false, draw = null, train = 0) { // 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). G.league = G.net || train ? 1 : prof.lg; - const band = leagueOf(G.league).band; - // the arcade ladder opens at the league's own bottom rung and never leaves the band: past its - // top the league simply has nobody stronger, so the top body walks on again (see matchGameWon) - G.match = { opp: train || (G.net ? 1 : band[0]), ending: false, train: !!train, felled: 0 }; + // 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. + // The tempo shifts per band border (gameplay.leaguePace reads the rung's own league). + G.match = { opp: train || 1, ending: false, train: !!train, felled: 0 }; // THE TOURNAMENT rides the whole match machinery and differs in exactly three places: who walks // on (a rung DRAWN from the round's band — cupDraw sets d.opp, so every ladder dial downstream // scales off the drawn rung untouched), how deep the body's pool is (matchOpponent multiplies diff --git a/src/config.js b/src/config.js index 294bb5d..49d7f51 100644 --- a/src/config.js +++ b/src/config.js @@ -825,7 +825,9 @@ export const COURTS = [ // baked in — the button's own name/sub render over a scrim, so the caption stays crisp. export const MODES = [ { id: 'classic', name: 'CLASSIC TENNIS', sub: 'REAL TENNIS · BEST OF THREE SETS', art: 'mode_classic' }, - { id: 'zombie', name: 'ARCADE', sub: 'ONE GAME PER OPPONENT · BLOOD ONLY', art: 'mode_arcade' }, + // THE CLIMB (2026-08-29): the arcade is the survival marathon through the WHOLE earned ladder, + // bottom up — its length grows with the career; the cup stays the league fixture. See beats. + { id: 'zombie', name: 'ARCADE', sub: 'THE WHOLE CLUB, BOTTOM UP · BLOOD ONLY', art: 'mode_arcade' }, // `tag` is the poster's corner chip (main.banner): the cup is THE WAY UP — winning your // league's tournament is the one route to promotion, and the picker should say so standing. { id: 'cup', name: 'THE TOURNAMENT', sub: 'YOUR LEAGUE\'S ROUNDS · WIN IT TO RISE', art: 'mode_cup', @@ -910,6 +912,9 @@ export const LEAGUES = [ { id: 5, name: 'THE HONOURS BOARD', court: 'members', band: [16, 18], color: '#e8b23a' }, ]; export const leagueOf = lg => LEAGUES[Math.min(LEAGUES.length, Math.max(1, lg | 0 || 1)) - 1]; +// 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 tournament, PER LEAGUE. Same shape CUP_ROUNDS always had (name/band/need) — round one is a // single game so a run opens fast, and the drawn body's pool still scales with `need` diff --git a/src/gameplay.js b/src/gameplay.js index 28e7a62..8cb5c47 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, 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, 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'; @@ -163,11 +163,15 @@ export const swayOf = aim => aim.mag === undefined ? aim.ax // as flat rather than resetting the lesson it just failed to use. export const flatCount = (streak, aim) => (aim && Math.abs(swayOf(aim)) > 0.15 ? 0 : streak + 1); -// THE LEAGUES' pace tier: solo match modes run faster per league, everything else is factor 1 — -// league 1 IS the shipped game (the calibration rule). Multiplies ball pace on BOTH rackets -// (doHit, both serve paths) and the opponent's legs (rzChaseSpeed), so the world speeds up as one -// piece and the reach tests keep believing the legs they measure. Netplay is a human and exempt. -export const leaguePace = () => G.match && !G.net ? 1 + (G.league - 1) * TUNE.leaguePace : 1; +// THE LEAGUES' pace tier, PER TERRITORY (2026-08-29, THE CLIMB): the tempo hangs on the band of +// the CURRENT OPPONENT, not on the player's league — the low ladder plays at its own shipped +// tempo and the world audibly pulls away as a climb crosses each band border. In the cup the two +// readings coincide (the pool IS your band); the training court now practises a body at the +// tempo you would really meet it at. Multiplies ball pace on BOTH rackets (doHit, both serve +// paths) and the opponent's legs (rzChaseSpeed), so the world speeds up as one piece and the +// reach tests keep believing the legs they measure. Netplay is a human and exempt. +export const leaguePace = () => G.match && !G.net + ? 1 + (leagueOfRung(G.match.opp).id - 1) * TUNE.leaguePace : 1; // THE DUEL LEARNS (the leagues, phase 4): 0 fresh, 1 at TUNE.duelRampGames — gore's own clock, // so game 0 is exactly the shipped duel and the league never rekeys the director. Exported for diff --git a/src/main.js b/src/main.js index 9d31de0..73486be 100644 --- a/src/main.js +++ b/src/main.js @@ -303,9 +303,24 @@ swatches('kitRow', 'kitName', KITS, 'kit', () => pickedKit, id => pickedKit = id swatches('skinRow', 'skinName', SKINS, 'skin', () => pickedSkin, id => pickedSkin = id); refreshLook(); -// THE LEAGUE PANEL (the leagues): the setup's league-mode face — your league writ large and the -// band's roster with your all-time felled counts (stats' per-foe tallies, key 'L'+index). The -// identity block this replaces still shows for every non-league mode; the card owns it anyway. +// THE CLIMB PANEL: the arcade's setup face — the marathon's length (grown by every promotion) +// and your best run. Reuses the league panel's furniture; the foes row stays empty (up to 18 +// tiles is a wall, and the training picker already shows the roster face-up). +function buildClimbPanel() { + const lg = leagueOf(profile.prof.lg); + document.getElementById('lgBadge').src = `assets/badge_l${lg.id}.png?v=${SPRV}`; + const name = document.getElementById('lgName'); + name.textContent = 'THE CLIMB'; + name.style.color = ''; + const best = stats.read('all', undefined, stats.MODE_MATCH).longest; + document.getElementById('lgSub').textContent = + `THE CLUB FROM THE BOTTOM — ${lg.band[1]} STAND` + (best ? ` · BEST RUN: ${best} FELLED` : ''); + document.getElementById('lgFoes').innerHTML = ''; +} + +// THE LEAGUE PANEL (the leagues): the cup's setup face — your league writ large and the band's +// roster with your all-time felled counts (stats' per-foe tallies, key 'L'+index). The identity +// block these panels replace still shows for every other mode; the card owns it anyway. function buildLeaguePanel() { const lg = leagueOf(profile.prof.lg); const badge = document.getElementById('lgBadge'); @@ -336,27 +351,26 @@ function buildLeaguePanel() { // On the LEAGUE MODES the player block steps aside for the league panel (Oliver: the identity is // on the card now — the setup should show the league and who of it you have already beaten). function syncSetup() { - const leagueMode = pickedMode === 'zombie' || pickedMode === 'cup'; - document.getElementById('who').style.display = leagueMode ? 'none' : 'flex'; - document.getElementById('lgPanel').style.display = leagueMode ? 'block' : 'none'; - document.getElementById('s01').textContent = leagueMode ? '01 · YOUR LEAGUE' : '01 · PLAYER'; - if (leagueMode) buildLeaguePanel(); + const cupMode = pickedMode === 'cup', climbMode = pickedMode === 'zombie'; + document.getElementById('who').style.display = cupMode || climbMode ? 'none' : 'flex'; + document.getElementById('lgPanel').style.display = cupMode || climbMode ? 'block' : 'none'; + document.getElementById('s01').textContent = + cupMode ? '01 · YOUR LEAGUE' : climbMode ? '01 · THE CLIMB' : '01 · PLAYER'; + if (cupMode) buildLeaguePanel(); else if (climbMode) buildClimbPanel(); if (!courtUnlocked(picked)) picked = leagueOf(profile.prof.lg).court; - // ...and the COURT section stops pretending to be a choice on the league modes (Oliver: a - // league HAS its court — start() has forced it all along): only the league's own row shows, - // ringed, as the venue. Every other mode gets the full pickable list. This runs AFTER the - // generic repaint below would, so it has the last word on .on/.lock. + // the COURT section: the CUP is the league fixture — one ringed row, the home venue, no choice + // (Oliver: a league HAS its court). The climb and every other mode keep the full pickable list. const homeCourt = leagueOf(profile.prof.lg).court; - document.getElementById('s02').innerHTML = leagueMode + document.getElementById('s02').innerHTML = cupMode ? '02 · HOME COURTSET BY YOUR LEAGUE' : '02 · COURT'; - courtList.classList.toggle('fixed', leagueMode); + courtList.classList.toggle('fixed', cupMode); for (const el of courtList.children) { const c = COURTS.find(x => x.id === el.dataset.id), l = courtLeague(el.dataset.id); const open = courtUnlocked(el.dataset.id); - el.classList.toggle('lock', !open && !leagueMode); - el.classList.toggle('on', leagueMode ? el.dataset.id === homeCourt - : open && el.dataset.id === picked); - el.style.display = leagueMode && el.dataset.id !== homeCourt ? 'none' : ''; + el.classList.toggle('lock', !open && !cupMode); + el.classList.toggle('on', cupMode ? el.dataset.id === homeCourt + : open && el.dataset.id === picked); + el.style.display = cupMode && el.dataset.id !== homeCourt ? 'none' : ''; // the sub line carries the lock and names the price — the tile stays visible, as a goal should el.querySelector('i').textContent = open ? c.sub : 'LOCKED — REACH ' + l.name; } @@ -440,7 +454,7 @@ for (const c of COURTS) { // Tapping a tile SELECTS it and nothing more. It used to start the match on the spot, which gave // you no way to look at the three of them and no way back out of the screen. b.addEventListener('click', () => { - if (pickedMode === 'zombie' || pickedMode === 'cup') return; // the league picks the venue + if (pickedMode === 'cup') return; // the cup is the league fixture — the league picks if (!courtUnlocked(c.id)) return; // locked: the row itself says which league opens it picked = c.id; for (const el of courtList.children) el.classList.toggle('on', el === b); @@ -541,7 +555,7 @@ function buildTrain() { const top = Math.min(LADDER, leagueOf(profile.prof.lg).band[1]); for (let n = 1; n <= top; n++) { const f = matchFoe(n), b = document.createElement('button'); - b.innerHTML = `${f.name}RUNG ${n}`; + b.innerHTML = `${f.name}No. ${n}`; b.addEventListener('click', () => { trainRung = n; start('train'); }); trainList.appendChild(b); } @@ -669,8 +683,8 @@ const boardFor = m => m === 'zombie' || m === 'cup' || m === 'train' ? stats.MOD function summary() { const t = TOURS.find(x => x.id === pickedTour) || TOURS[0]; const lg = leagueOf(profile.prof.lg); - const leagueMode = pickedMode === 'zombie' || pickedMode === 'cup'; - const c = COURTS.find(x => x.id === (leagueMode ? lg.court : picked)) || COURTS[0]; + const cupMode = pickedMode === 'cup'; + const c = COURTS.find(x => x.id === (cupMode ? lg.court : picked)) || COURTS[0]; const m = MODES.find(x => x.id === pickedMode) || MODES[0]; // the mark to beat is the PICKED MODE's own, because the three of them do not measure the same // thing — see the record-shape note in stats.endRun. The cup banks through endRun as MODE_MATCH @@ -681,7 +695,7 @@ function summary() { const name = m.id === 'classic' ? m.name + ' · ' + (DIFFS.find(d => d.id === pickedDiff) || DIFFS[1]).name : m.name; document.getElementById('sSum').innerHTML = - `${name} · ${t.name.replace(' SINGLES', '')} · ${c.name}${leagueMode + `${name} · ${t.name.replace(' SINGLES', '')} · ${c.name}${cupMode ? ' · LEAGUE ' + lg.id : ''}` // training banks nothing, so there is no mark to beat and the footer must not invent one + `${m.id === 'train' ? 'NOTHING AT STAKE — JUST REPS' @@ -718,9 +732,9 @@ function start(modeId = pickedMode) { if (!menuUp()) return; selectTour(pickedTour); selectKit(pickedKit); selectSkin(pickedSkin); - // THE LEAGUES: a league match is played on the league's own court — the pick stays yours in - // classic, gore, netplay and training (among the unlocked courts). - selectCourt(modeId === 'zombie' || modeId === 'cup' ? leagueOf(profile.prof.lg).court : picked); + // THE LEAGUES: the CUP is the league fixture and plays the league's own court; every other + // mode — the climb included, it is an exhibition — plays your pick (among the unlocked). + selectCourt(modeId === 'cup' ? leagueOf(profile.prof.lg).court : picked); menuDuck(true); // the theme belongs to the menus — hand the court over to the ambience intro.hidden = true; ambient(); voice('quiet_please'); // low atmosphere bed starts with the first gesture // ...and the mode the setup screen picked. THREE LINES, because that is genuinely all the modes diff --git a/test.js b/test.js index cad210d..6afc046 100644 --- a/test.js +++ b/test.js @@ -2214,22 +2214,22 @@ test('PROFILE: zombie names fit the board and its charset, and the profile round prof.prof.lg = 1; prof.save(); }); -test('THE LEAGUES: the arcade opens at its band, climbs inside it, and counts felled bodies', () => { +test('THE CLIMB: the arcade runs the whole earned ladder from the bottom and clamps at your top', () => { prof.prof.lg = 2; assert.ok(_startMatch(), 'the arcade starts'); G.modal = null; G.modalQ.length = 0; G.mode = 'splatter'; assert.equal(G.league, 2, 'the run wears the profile league'); const d = G.match; - assert.equal(d.opp, 5, "league two opens at its band's bottom rung"); - for (let b = 0; b < 6; b++) { + assert.equal(d.opp, 1, 'the climb opens at the very bottom of the ladder'); + for (let b = 0; b < 8; b++) { G.interlude.introT = 0; for (let k = 0; k < 4; k++) { resolveInterlude('P'); G.modal = null; G.modalQ.length = 0; } for (let i = 0; i < 6 * 60 && G.matchFall; i++) { G.modal = null; G.modalQ.length = 0; update(DT); } passMopBeat(); } - assert.equal(d.opp, 8, 'the ladder clamps at the top of the band — the cup is the only way up'); - assert.equal(d.felled, 6, 'every felled body counts, clamp or no clamp'); + assert.equal(d.opp, 8, "the ladder clamps at your league's top — the cup is the only way up"); + assert.equal(d.felled, 8, 'every felled body counts, clamp or no clamp'); // ...and the repeat walk-on ANSWERS the "what now?" it poses: fell the top body once more and // read its intro modal instead of wiping it G.interlude.introT = 0; @@ -2267,18 +2267,20 @@ test('THE LEAGUES: the top league crowns a champion instead of promoting', () => assert.ok(pr && pr.champion, 'the ceremony parked is the champion one'); }); -test('THE LEAGUES: the pace tier scales the solo match world and nothing else', () => { - G.match = { opp: 1 }; G.league = 1; - assert.equal(leaguePace(), 1, 'league one IS the shipped game'); - G.league = 5; - assert.ok(leaguePace() > 1.2, 'the top league is a visibly faster world'); - const climbs = [1, 2, 3, 4, 5].map(l => { G.league = l; return leaguePace(); }); - for (let i = 1; i < climbs.length; i++) assert.ok(climbs[i] > climbs[i - 1], 'each league is faster'); +test("THE LEAGUES: the pace tier follows the OPPONENT's territory, and nothing else", () => { + G.match = { opp: 1 }; + assert.equal(leaguePace(), 1, 'the bottom of the ladder IS the shipped game'); + G.match.opp = 4; + assert.equal(leaguePace(), 1, '...flat inside a band: league one all plays league-one tempo'); + G.match.opp = 18; + assert.ok(leaguePace() > 1.2, "the champion's territory is a visibly faster world"); + const climbs = [1, 5, 9, 13, 16].map(o => { G.match.opp = o; return leaguePace(); }); + for (let i = 1; i < climbs.length; i++) + assert.ok(climbs[i] > climbs[i - 1], 'each band border pulls the tempo up'); G.net = { on: true }; assert.equal(leaguePace(), 1, 'netplay is a human and exempt'); - G.net = null; G.match = null; G.league = 5; + G.net = null; G.match = null; assert.equal(leaguePace(), 1, 'outside the match modes the factor is 1 — gore keeps its own director'); - G.league = 1; }); test('THE DUEL LEARNS: its skill ramps with the run, and game zero is the shipped duel', () => {