diff --git a/PROJECT.md b/PROJECT.md index a78665d..e8f3d1d 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -5531,6 +5531,60 @@ day, textbook ... verdecken manchmal den Ball."* serve's landing). Gore keeps its words on the bodies they belong to. Checked in the pane: PERFECT under him, ball clear. +### Step 4, measured first: the ladder cannot climb, because points are decided by the player's whiffs (2026-10-14, v372) +Oliver on v371: *"okay, aber der Text wird auf dem iPhone teilweise links abgeschnitten"*, and *"es kann auch +sein, dass durch die geänderte Mechanik alles etwas schwerer geworden ist"*. +- **The praise word is kept on screen** (`render`, the pops loop): v371 centres it under the player's feet, + and a player out wide pushed "SHOT OF THE DAY" past the left edge. Every pop is clamped to the canvas by + its measured width. Gore's words get the same clamp. +- **`tools/rally-sim.mjs` grew a human bot** (`BOT=human`, the default): it lands its finger on the timed + ring with `JIT` ms of gaussian spread (clamped ±2σ) plus a fixed `BIAS`, taps with a 120 ms lift, lobs or + passes a net man, drops a deep one. `CLIMB=1` plays the Climb's step boost on a pinned rung (training + with `train` off and hp topped up; driving the real Climb ended the run). `XP` sets the level, `WHY=1` + files every lost point under how it came about, `PROBE=1` times ring vs contact, `JSON=1` for + aggregation. Run 8 seeds and pool them: one seed's 40-50 points per rung is ±7%. +- **Measured** (Climb, level 26, 8 seeds × 500 s, point win %; σ40 ≈ a practised thumb, σ60 an average one): + + | rung | 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | + |---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| + | σ40 | 78 | 65 | 77 | 57 | 50 | 44 | 40 | 62 | 48 | 40 | 65 | 41 | 49 | 64 | 40 | 61 | 45 | + | σ60 | 61 | 39 | 52 | 38 | 32 | 30 | 29 | 44 | 27 | 29 | 48 | 23 | 31 | 51 | 29 | 49 | 34 | + + The curve falls over the first six rungs and is then FLAT, zigzagging by style: the three net rushers and + the second slicer are 15-20 points easier than their neighbours, and the Champion is the Runner. +- **Why it cannot climb.** Nearly every lost point (`WHY=1`) is the player's own "MISS tap too early"; the + CPU's whiff rate is `matchFlub`·0.76^step, ~1% at the Runner and ~0.07% at the Champion. The auto-run + makes the player defensively near-perfect, so the CPU's pace, width, quality and style barely decide a + point; the player's whiff rate decides, and no opponent dial touches it. Three experiments, all measured: + 1. **A quality ramp** (every rung's q band on a line from the shambler's to the Brute's, smart aim from rung + 6): no change outside the noise. Reverted. + 2. **A follow-through** (a groundstroke swing meeting a ball up to 60 ms after its window at q 0.05, + instead of whiffing): the bot won 80-99% everywhere, with 20-40-shot rallies. Reverted. + 3. **On top of it, the CPU putting sitters away** (a q < 0.5 ball answered at q 0.95 to the open court, + with a chance from 0.2 at the shambler to 0.85 at the champion): no change. The auto-run reaches the + put-away too. Reverted. +- **Where the "harder" comes from.** The early whiff edge. A tap a little early starts the swing while + the ball is in the air, the swing waits for the bounce (`letBounce`), and the groundstroke window has run + out by then: a whiff at ~70 ms early (a tap 120 ms LATE still meets it). Before step 1 the same ball was + volleyed in the 1.8x window, so the edge sat near 130 ms. Sweep at level 26, rung 8, perfect thumb plus a + fixed bias: −70 ms is 56% contact, −50 ms q 0.24, 0 and +50 ms q 0.99, +70 ms q 0.52. The ring itself is + centred: contact comes 4-10 ms after its predicted moment, which is frame quantisation. +- **Shipped: the quality curve loses its cliff** (`TUNE.qSeamless`, rally modes, player only). q was 1 inside + `perfectMs` and `1 - off/half` outside it: a groundstroke fell from 1 to 0.43 one ms past PERFECT. It now + falls from 1 at PERFECT's edge to the window's edge. −30 ms is q 0.86 (was 0.61), +70 ms q 0.76 (was 0.52). + The window, PERFECT and the whiff edge are unchanged, and **the bot's win rate does not move**; this is a + feel fix, not the difficulty fix. Test: a tap 3 frames early of the kiss returns at q >= 0.45 (0.33 on + the old curve). +- **The decision for Oliver**, because each option changes what the game IS: + - (a) **Keep the error race and dial it**: the CPU's whiff curve gets a floor and a gentler decay so the + ladder slopes smoothly, styles get compensated. Cheap, predictable, still "who whiffs first". + - (b) **The follow-through, plus a CPU that can WIN points**: an early tap becomes a weak ball instead of a + whiff, and a sitter is punished by a put-away the auto-run does NOT fully cover (the run reacts late + after a weak ball of your own, or a put-away's pace is beyond its legs). Points then turn on shot + quality, which is what the opponent dials scale. More tennis, more tuning. + - (c) **Widen the early whiff edge only** (a smaller tail), keeping the error race: undoes most of the + "harder" without changing the model. + # OPEN — leftovers, in rough priority order Nothing here is broken; these are known gaps and unverified risks. @@ -5538,8 +5592,9 @@ Nothing here is broken; these are known gaps and unverified risks. 0. **THE RALLY GOES FLAT (2026-10-13), the top item.** Every match converged on serve, both at the net, reflex volleys, won by a wrong-foot. Step 1 (the court gets its length) SHIPPED v361, awaiting the phone verdict; the tap timing + leg knee (v362), the soft ball (v363, drop/lob split v364) and the - opponent styles (v364) followed. Awaiting the phone verdict on v364. See the section of that name - above. Fallback tag `rally-baseline-v360`. + opponent styles (v364) followed; the swing-log fixes ran to v371. **Step 4 (v372) is BLOCKED on a + design decision**: the ladder cannot climb while points are decided by the player's whiffs. See "Step + 4, measured first" above for options (a)/(b)/(c). Fallback tag `rally-baseline-v360`. 1. **`RES` 4 is unverified on a real phone under load.** It nearly doubled the fill rate (810 -> 1080 device px across). Oliver has not reported stutter, but he also has not played a late horde since. diff --git a/index.html b/index.html index 4a4bf03..6d2cb83 100644 --- a/index.html +++ b/index.html @@ -1140,6 +1140,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/src/config.js b/src/config.js index e811dd3..20c6371 100644 --- a/src/config.js +++ b/src/config.js @@ -153,6 +153,9 @@ export const TUNE = { // screen: a short swipe is a safe half-court ball, and only a committed // one goes hunting the line, which is what makes going OUT a chosen risk. perfectMs: 45, // |offset from window center| for PERFECT + qSeamless: 1, // rally modes: past perfectMs, q falls from 1 to the window edge with no step. + // 0 = the old curve, 1 - off/half, which dropped a groundstroke 1 ms past + // PERFECT straight to q 0.43 (step 4, 2026-10-14 — see gameplay.trySwing) // The tennis CPU only exists in the OPENING match (after the transition, tennis never comes // back — duels use racket* dials), so these three are the "how long until the first game is won" // knobs. Testers needed several games to get past him: softer cap, twice the whiffs, and diff --git a/src/gameplay.js b/src/gameplay.js index 0914f9e..f8d80c6 100644 --- a/src/gameplay.js +++ b/src/gameplay.js @@ -1687,6 +1687,15 @@ function trySwing(ch, isAI, qCap = TUNE.aiQualityMax) { const center = TUNE.swingWindup + win / 2; const off = Math.abs(t - center) / (win / 2); let q = Math.max(0.05, 1 - off); + // ...WITHOUT A CLIFF in the rally modes (step 4, 2026-10-14). The line above runs from the centre, so + // q was 1 inside perfectMs and 0.43 a millisecond outside it: a groundstroke's half-window is ~80 ms + // and PERFECT owns 45 of them. The volley's 1.8x window used to hide that (q 0.69 at the edge); step + // 1 made the rally groundstrokes, and a 30 ms early tap came back at q 0.6 (0.89 now). Same window, + // same PERFECT, same whiff edge — only the step between them is gone. It does NOT move the ring bot's + // win rate: points are decided by whiffs, not weak balls (see PROJECT.md, step 4's measurement). + const p = TUNE.perfectMs / 1000, half = win / 2; + if (rallyFeel() && !isAI && TUNE.qSeamless && half > p) + q = Math.max(0.05, 1 - (Math.abs(t - center) - p) / (half - p)); if (Math.abs(t - center) * 1000 < TUNE.perfectMs) q = 1; ch.facing = dx >= 0 ? 1 : -1; // forehand right (righty), backhand left → mirrored sprite // the LIVE swipe outranks the released one: since the crossing fires the swing (input.js), the diff --git a/src/render.js b/src/render.js index 6dc7500..bf478a1 100644 --- a/src/render.js +++ b/src/render.js @@ -2883,7 +2883,11 @@ export function render() { ctx.save(); ctx.globalAlpha = Math.min(1, f * 3); // holds, then a quick fade at the end ctx.font = B(13); ctx.textAlign = 'center'; ctx.textBaseline = 'top'; - shadowText(p.text, Math.round(pr.x), Math.round(pr.y - (p.still ? 0 : (1 - f) * 14)), p.col || '#ffffff'); + // kept on screen: a word under a player out wide (the rally's praise, v371) is centred on his feet, + // and "SHOT OF THE DAY" off a man near the left tramline lost its first letters on the iPhone + const hw = ctx.measureText(p.text).width / 2 + 3; + const x = hw * 2 < W ? Math.min(W - hw, Math.max(hw, pr.x)) : W / 2; + shadowText(p.text, Math.round(x), Math.round(pr.y - (p.still ? 0 : (1 - f) * 14)), p.col || '#ffffff'); ctx.restore(); } // (a PERFECT-contact spark ring lived here for one build — Oliver's verdict: too much. The diff --git a/test.js b/test.js index 30aeef9..d69effd 100644 --- a/test.js +++ b/test.js @@ -1143,6 +1143,35 @@ test('a SHORT ball is an approach, a deep one keeps the baseliner back, and nobo } finally { TUNE.matchFlub = flub; } }); +test('a groundstroke a little off PERFECT is a little worse, not a weak ball (no cliff past perfectMs)', () => { + // Step 4's measurement (2026-10-14): q was 1 inside perfectMs and 1 - off/half outside it, so a + // groundstroke (half-window ~80 ms) fell from 1 to 0.43 a millisecond past PERFECT, and a tap 30 ms + // before the ring's kiss came back at q ~0.6, ~45 ms at 0.32 (0.57 now). The rally modes now fall from 1 at the edge of + // PERFECT to the window's edge. The finger lands 3 frames EARLY of the kiss, every ball. + const assist = TUNE.autoAssist; TUNE.autoAssist = 1.15; + try { + startMatch(); G.interlude.introT = 0; G.match.opp = 6; + for (let i = 0; i < 2.2 * 60; i++) update(DT); + let armed = false, incoming = 0, returned = 0, sumQ = 0; + for (let i = 0; i < 90 * 60; i++) { + G.modal = null; G.modalQ.length = 0; G.hp = G.hpMax; G.match.opp = 6; + if (G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0)) { if (i % 40 === 0) keyPressed('Space'); } + else if (G.phase === 'toss') { if (ball.vy < 0.4 && ball.y > 2.4) keyPressed('Space'); } + else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O' && ball.vz > 0 && !armed) { + const c = ringCue(); + if (c && c.eta - c.sweet <= 3 / 60 + 1e-6 && c.eta - c.sweet > 2 / 60) { armed = true; incoming++; keyPressed('Space'); } + } + const before = ball.lastHit; + update(DT); + if (armed && before === 'O' && ball.lastHit === 'P') { returned++; sumQ += ball.pow; armed = false; } + else if (armed && (!ball.active || G.phase !== 'rally')) armed = false; + } + assert.ok(incoming >= 20, 'a real sample: ' + incoming); + assert.ok(returned / incoming >= 0.9, `an early-ish tap still returns: ${returned}/${incoming}`); + assert.ok(sumQ / returned >= 0.45, 'and still returns a real ball (0.32 on the old curve): mean q ' + (sumQ / returned).toFixed(2)); + } finally { TUNE.autoAssist = assist; } +}); + test('a TAP is timed from touch-down: a normal 120 ms tap on the gold ring still returns cleanly', () => { // Oliver, 2026-10-14: "manchmal scheine ich den Ball trotz gutem Timing nicht mehr zu bekommen". // A tap only proves it is a tap when the finger LIFTS, so it swung ~100-150 ms late — inside the diff --git a/tools/rally-sim.mjs b/tools/rally-sim.mjs index cd218eb..a0fbdc8 100644 --- a/tools/rally-sim.mjs +++ b/tools/rally-sim.mjs @@ -3,6 +3,8 @@ // a fixed tap window, a random lane/deep flick) and prints WHERE each side meets the ball, how often // it volleys, how long each shot gives the other side, and where rally balls land. It measures the // shape of a rally, not a win rate — the bot is not Oliver. See PROJECT.md "THE RALLY GOES FLAT". +// Since step 4: BOT=human (default; JIT, BIAS ms), CLIMB=1, XP, WHY=1, PROBE=1, LOG=1|all, SUMMARY=1, +// JSON=1 — see PROJECT.md "Step 4, measured first". Pool 8 seeds before reading a win rate. // ponytail: the platform stubs are copied from test.js; fold this into a test once the rework lands. let _seed = (Number(process.env.SEED) || 20260811) >>> 0; Math.random = () => { // xorshift32 — small, fast, good enough for jitter @@ -84,14 +86,49 @@ const SECS = Number(process.env.SECS || 240); TUNE.autoAssist = 1.15; const pct = (a, f) => a.length ? Math.round(100 * a.filter(f).length / a.length) : 0; const med = a => { const s = [...a].sort((x, y) => x - y); return s.length ? s[Math.floor(s.length / 2)] : NaN; }; -const rows = [], land = [], pts = []; +// BOT=human (the default): taps on the timed gold ring like a player (JIT ms of timing spread, a +// 120 ms tap), and plays the game a little — a lob or a pass against a net man, the odd drop when +// the opponent hangs back. BOT=dist is the original fixed-distance tapper with random flicks. +const HUMAN = (process.env.BOT || 'human') === 'human'; +const JIT = Number(process.env.JIT ?? 60) / 1000, LAG = 7; +const gauss = () => Math.sqrt(-2 * Math.log(1 - Math.random())) * Math.cos(2 * Math.PI * Math.random()); +const probe = []; +const bot = { armed: false, fire: -1, jit: null, swipe: null }; +const sw = (dx, dy, mag) => { const n = Math.hypot(dx, dy) || 1, fx = dx / n, fy = dy / n; + return { ax: Math.abs(fx) > 0.38 ? Math.sign(fx) : 0, az: Math.abs(fy) > 0.38 ? Math.sign(fy) : 0, fx, fy, mag }; }; +function humanAim() { + const rz = process.env.CLASSIC ? state.O : zombies.find(z => z.variant === 'r'); + const oz = rz ? Math.abs(rz.z - COURT.netZ) : 12, r = Math.random(); + const away = rz && rz.x > 0 ? -1 : 1; // the side he is not on + if (oz < TUNE.netFrom) return r < 0.4 ? sw(0, 1, 150) : sw(away, -0.3, 120); // net man: lob him or pass him + if (oz > COURT.netZ + 0.5 && Math.abs(P.z - COURT.netZ) < COURT.netZ - 1.5 && r < 0.15) return sw(0, 1, 50); // drop + if (r < 0.45) return sw(away, -0.2, 110); // to the open side + if (r < 0.65) return sw(0, -1, 80); // deep through the middle + return null; // a plain tap +} +const rows = [], land = [], pts = [], whys = []; for (const rung of RUNGS) { - restartRun(); + restartRun(); Object.assign(bot, { armed: false, fire: -1, jit: null, swipe: null }); prof.prof.lg = 5; prof.prof.xp = Number(process.env.XP || 0); prof.takePromo(); G.mode = 'splatter'; G.everTransitioned = true; state.setupPoint(); const cfgm = await import(R + 'config.js'); if (process.env.CLASSIC) { G.mode = 'tennis'; G.everTransitioned = false; G.classic = true; G.diff = cfgm.DIFFS[rung]; state.setupPoint(); } + // CLIMB=1: the arcade run (its own step boost at the bottom), pinned to this rung like the suite does + else if (process.env.CLIMB) { beats.startMatch(false, null, rung); G.match.train = false; } else beats.startMatch(false, null, rung); + const why = {}; + let prevE = null, lastHitE = null; // LOST is filed under how it came about: the player's last act before it + G.missLog.push = function (e) { + if (e.result === 'LOST') { + const k = /never swung/.test(e.reason) ? 'never swung' : prevE && prevE.result === 'MISS' ? 'MISS ' + prevE.reason.split(' — ')[0].replace(/[\d.]+m?s?/g, '#').slice(0, 30) + : lastHitE && /q=0\.[0-4]/.test(lastHitE.reason) ? 'after weak hit q<.5' : 'after good hit'; + why['L: ' + k] = (why['L: ' + k] || 0) + 1; + } else if (e.result === 'WON') why.WON = (why.WON || 0) + 1; + if (e.result === 'HIT') lastHitE = e; + if (e.result === 'WON' || e.result === 'LOST') lastHitE = null; + if (e.result !== 'THEM') prevE = e; + return Array.prototype.push.call(this, e); + }; G.modal = null; G.modalQ.length = 0; for (let i = 0; i < 2.2 * 60; i++) { G.modal = null; G.modalQ.length = 0; update(DT); } const lands = []; @@ -101,11 +138,24 @@ for (const rung of RUNGS) { let swipe = null, pts0 = [...score.pts], net = { P: false, O: false }; const won = { P: 0, O: 0, Pnet: 0, PnetWon: 0, Onet: 0, OnetWon: 0 }; for (let i = 0; i < SECS * 60; i++) { - G.modal = null; G.modalQ.length = 0; + G.modal = null; G.modalQ.length = 0; if (process.env.CLIMB && G.match) { G.match.opp = rung; G.match.train = false; G.hp = G.hpMax; } if (G.mode === 'eaten' || G.done) { G.hp = G.hpMax; } t += DT; + if (HUMAN && (ball.lastHit !== 'O' || !ball.active || G.phase !== 'rally')) { bot.jit = null; if (i !== bot.fire) { bot.armed = false; bot.fire = -1; } } if (G.phase === 'pretoss' && !(G.interlude && G.interlude.introT > 0)) { if (i % 40 === 0) keyPressed('Space'); } else if (G.phase === 'toss') { if (ball.vy < 0.4 && ball.y > 2.4) keyPressed('Space'); } + else if (HUMAN) { + // the finger lands when the ring says "now" give or take a human's timing (JIT ms, gaussian), + // and a tap lifts LAG frames later carrying its age; a flick fires on the land (its crossing) + if (G.phase === 'rally' && ball.active && ball.lastHit === 'O' && ball.vz > 0 && !bot.armed) { + const c = gp.ringCue(); + if (c) { + if (bot.jit === null) bot.jit = Math.max(-2, Math.min(2, gauss())) * JIT + Number(process.env.BIAS || 0) / 1000; + if (c.eta - c.sweet + bot.jit <= 0 || c.eta <= 0) { bot.land = { i, eta: c.eta, sweet: c.sweet, b: ball.bounces }; bot.armed = true; bot.jit = null; bot.swipe = humanAim(); bot.fire = i + (bot.swipe ? 0 : LAG); } + } + } + if (i === bot.fire) { G.swipe = bot.swipe; keyPressed('Space', bot.swipe ? 0 : LAG / 60, !!bot.swipe); } + } else if (G.phase === 'rally' && ball.active && ball.lastHit === 'O') { const d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8); if (d < 2.9 && d > 2.2 && P.swingT < 0) { @@ -117,7 +167,10 @@ for (const rung of RUNGS) { keyPressed('Space'); } } + const lh = ball.lastHit; + if (process.env.PROBE && lh === 'O' && bot.land && P.swingT >= 0) bot.land.sw = P.swingT, bot.land.d = Math.hypot(ball.x - P.x, (ball.z - P.z) * 0.8), bot.land.y = ball.y, bot.land.bb = ball.bounces; update(DT); + if (process.env.PROBE && lh === 'O' && ball.lastHit === 'P' && bot.land) { const L = bot.land; probe.push({ late: Math.round(((i - L.i) / 60 - L.eta) * 1000), sw: Math.round(L.sw * 1000), q: +ball.pow.toFixed(2), bAtLand: L.b, bAtHit: L.bb, d: +L.d.toFixed(2), y: +L.y.toFixed(2) }); bot.land = null; } if (ball.lastHit !== 'O') G.swipe = null; const rz = process.env.CLASSIC ? state.O : zombies.find(z => z.variant === 'r'); if (ball.active && ball.lastHit !== prevLast && (ball.lastHit === 'P' || ball.lastHit === 'O')) { @@ -161,7 +214,15 @@ for (const rung of RUNGS) { land.push({ rung, hitter: side, bounces: l.length, medLandFromBaseline: +med(l).toFixed(1), 'deep<3m%': pct(l, x => x < 3), 'svcbox>5.5m%': pct(l, x => x > 5.5), medPace: +med(v).toFixed(1) }); } - pts.push({ rung, Pwon: won.P, Owon: won.O, 'P net pts': won.Pnet, 'P net won%': won.Pnet ? Math.round(100 * won.PnetWon / won.Pnet) : '', 'O net pts': won.Onet, 'O net won%': won.Onet ? Math.round(100 * won.OnetWon / won.Onet) : '' }); + const oh = hits.filter(x => x.side === 'O'); + pts.push({ rung, foe: (zombies.find(z => z.variant === 'r')?.foe?.short) || '', 'P win%': Math.round(100 * won.P / Math.max(1, won.P + won.O)), + 'shots/pt': +(rallies.reduce((a, b) => a + b, 0) / Math.max(1, rallies.length)).toFixed(1), 'O at net%': pct(oh, x => x.fromNet < 6.4), Pwon: won.P, Owon: won.O, 'P net pts': won.Pnet, 'P net won%': won.Pnet ? Math.round(100 * won.PnetWon / won.Pnet) : '', 'O net pts': won.Onet, 'O net won%': won.Onet ? Math.round(100 * won.OnetWon / won.Onet) : '' }); + whys.push({ rung, ...why }); rows.push({ rung, side: 'rally', shots: rallies.length, medDistNet: med(rallies), '<6.4m(svcline)%': '', '<3.5m(net)%': '', 'volley%': '', medTimeToHit_s: 'shots/pt mean ' + (rallies.reduce((a, b) => a + b, 0) / rallies.length).toFixed(1) }); } -console.table(rows); console.table(land); console.table(pts); +if (!process.env.SUMMARY) { console.table(rows); console.table(land); } +if (process.env.PROBE) { const l = probe.map(p => p.late).sort((a, b) => a - b); console.log("contact after predicted entry, ms: n", l.length, "mean", (l.reduce((a, b) => a + b, 0) / l.length).toFixed(1), "p10", l[l.length * 0.1 | 0], "p50", l[l.length / 2 | 0], "p90", l[l.length * 0.9 | 0]); } +if (process.env.JSON) { console.log(JSON.stringify(pts)); process.exit(0); } +console.table(pts); +if (process.env.WHY) console.table(whys); +if (process.env.LOG) console.log(G.missLog.slice(process.env.LOG === "all" ? 0 : -25).map(e => `${e.at} ${e.result} ${e.reason}`).join("\n"));