From 787877bce66c14c0f4b90f79c88e8b3e3784b6b8 Mon Sep 17 00:00:00 2001 From: Oliver Rieger Date: Sat, 29 Aug 2026 12:53:12 +0200 Subject: [PATCH] The leagues, phase 4: the duel learns, tin tiers by league, level buffs, the board wears the badge Co-Authored-By: Claude Fable 5 --- PROJECT.md | 33 ++++++++++++++++++++++++- index.html | 3 ++- server.py | 6 ++++- src/config.js | 18 ++++++++++++++ src/gameplay.js | 40 +++++++++++++++++++++--------- src/main.js | 2 +- src/profile.js | 11 +++++++++ src/stats.js | 12 ++++++--- src/zombies.js | 8 +++--- test.js | 66 +++++++++++++++++++++++++++++++++++++++++++++++-- 10 files changed, 175 insertions(+), 24 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 5d9cdae..cdf3f14 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -491,6 +491,37 @@ 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 LEAGUES, PHASE 4 — the duel learns, tin tiers, the level is you (2026-08-29, STAGING ONLY) + +Phase 4, the last of the built plan. STAGING ONLY — the whole feature waits on Oliver's own +phone test before merging to main. + +- **THE DUEL LEARNS.** The diagnosis held: in a duel the racket zombie played with every ladder + term inert (chase collapsed to `racketSpeed`, read to flat `aiRead`, flub a flat 0.2, quality + capped 0.55) and only hp stiffened — at `duelFirst 2 / duelEvery 3` that made it a weapon + DISPENSER, the same inflation class the barrel economy was cured of. `gameplay.duelSkill()` + (0 → 1 over `TUNE.duelRampGames` 24, on `G.runGames` — gore's own clock, the league never + rekeys the director) now ramps its legs (`duelSpeedMax` +35%), read (`duelReadMax` 0.93), + whiff (`duelFlubCut`: 0.2 → 0.08) and return quality (`duelQBonus`: 0.55 → 0.75). Game 0 is + exactly the shipped duel (calibration rule). Losing already costs plenty (opponent game + + runGames++ + the 5-body surge + no tin/heal) — now it can actually happen late. +- **THE TIN TIERS** (settled: no starting loadout). `tinLadder()` (pure, tested): a barrel weapon + joins the duel pot when the RUN unlocks it (as ever) OR when the LEAGUE says so — + `config.TIN_LEAGUE` lasers at 3, rockets at 4 — and league 5 duels pay DOUBLE tins + (`TIN_DOUBLE_LEAGUE`, the card says ×2N). "Weapons are only ever won" stays literal. +- **THE LEVEL IS YOU**: `profile.bonus()` — level 1 = all factors 1 (shipped), scaling linearly + to the cap: melee cooldown FLOOR ×0.75 (the ceiling untouched), parry reach/arm ×1.25, hurt + grace ×1.35. Read at the dial sites (meleeCooldown, keyPressed's parry arm, zombies' parry + window + face-offset, hurtPlayer). Applies everywhere the dials do — gore included, which is + the cross-mode incentive the plan asked for. +- **THE BOARD WEARS THE BADGE**: score rows carry `lg` (server clean() bounds it 1-5, default 1; + context, never a sort key), `stats.submit` takes it as a parameter (profile cannot be imported + there — cycle), main passes `prof.lg`, and drawRanks renders the little shield before the name. + Old rows carry none and show none — no migration. +- Tests 121 → 124 (duel ramp shape + its caps stay fallible, tin tiers incl. the double tin and + the run-gate fallback, bonus() bounds + the grace landing on `G.hurtT`). Seeds fuzzed. + Server round-tripped live (`lg: 4` stored, served, and drawn as the navy shield on the board). + ## Session log: THE LEAGUES, PHASE 3 — the character card, badges, the skill trail (2026-08-29, STAGING ONLY) Phase 3. STAGING ONLY, like 1 and 2. @@ -601,7 +632,7 @@ before the wider group) — do not merge to main until his verdict. 118, seeds fuzzed. Pane-verified end to end (identity flow, POST 200, death screen with +16 XP and the bar; NOTE this pane session has rAF dead again — `T.step(1/60)` loops drove the clock). -## THE LEAGUES — the meta-progression plan (2026-08-29, AGREED, PHASE 1 BUILT — see log above) +## THE LEAGUES — the meta-progression plan (2026-08-29, AGREED; PHASES 1-4 BUILT ON STAGING — see the four logs above; phase 5 deliberately parked) Worked out with Oliver in discussion, decisions settled below. The goal in his words: an incentive to keep opening the game beyond beating the next opponent — levelling through play, with a clearly diff --git a/index.html b/index.html index d2c1070..66b05b3 100644 --- a/index.html +++ b/index.html @@ -290,6 +290,7 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat #cdBadges { display: flex; gap: 10px; align-items: flex-end; } #cdBadges img { height: 46px; image-rendering: pixelated; } #cdBadges img.dark { filter: grayscale(1) brightness(0.3); } + .rkbadge { height: 15px; margin-right: 6px; image-rendering: pixelated; flex: none; } /* THE LEVEL STRIP: the one meta number over a thin bar, riding the splash foot. The fill colour is the acid accent for now; the leagues will hand it the skill colour in phase 3. */ #xpRow { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; } @@ -794,6 +795,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one' && !window.Capacitor?.isNat - + diff --git a/server.py b/server.py index 45848fd..79479e4 100644 --- a/server.py +++ b/server.py @@ -126,7 +126,11 @@ def clean(payload): mode = str(payload.get('m', 'g')) if mode not in MODES: mode = 'g' # an unknown board is the default one, never a new one - return {'n': name, 'k': kills, 'g': games, 't': int(time.time()), 'id': who, 'm': mode} + try: # the league badge (THE LEAGUES): context, not a sort key + lg = max(1, min(5, int(payload.get('lg', 1)))) + except (TypeError, ValueError): + lg = 1 + return {'n': name, 'k': kills, 'g': games, 't': int(time.time()), 'id': who, 'm': mode, 'lg': lg} def public(rows): diff --git a/src/config.js b/src/config.js index a97540a..79b7e6f 100644 --- a/src/config.js +++ b/src/config.js @@ -688,6 +688,16 @@ export const TUNE = { // returner takes. Serve-scoped on purpose: the rally is unchanged. racketQMax: 0.55, // its return quality is capped here (slower, sloppier than the tennis CPU) racketFlub: 0.2, // chance the racket zombie whiffs a return + // THE DUEL LEARNS (the leagues, phase 4). At duelFirst 2 / duelEvery 3 with hp-only stiffening + // the duel was a weapon DISPENSER, not a gate — the same inflation class the barrel economy was + // cured of. Its skill now ramps with the RUN, on gore's own clock (G.runGames — the league buffs + // YOU, it never rekeys the director): legs, read, whiff and return quality all climb to these + // caps by duelRampGames. Game 0 is exactly the shipped duel (the calibration rule). + duelRampGames: 24, // games until the duel opponent plays at full skill + duelSpeedMax: 0.35, // its legs end 35% quicker than the shipped 2.8 m/s lumber + duelReadMax: 0.93, // its read climbs from aiRead 0.8 toward here — near-machine, never one + duelFlubCut: 0.6, // racketFlub 0.2 -> 0.08: late duels stop beating themselves + duelQBonus: 0.2, // racketQMax 0.55 -> 0.75: its returns get real pace late missHud: 0, // show the last miss reason on screen (full log: T.G.missLog) — off; dev-only killPips: 1, // splatter: show the kills-to-next-point pips on the scoreboard }; @@ -719,6 +729,14 @@ export const UI = { hot: '#d9705f', warm: '#e0cc72', pick: '#cbd06a', cream: '#e // palette is near it, so "pink means a weapon is in there" needs no explaining pink: '#ff5cf0' }; +// THE TIN TIERS (the leagues, phase 4; settled: tin-tier, NO starting loadout). The league +// unlocks what a won duel's tin can PAY, independent of the run's own barrel gates — "weapons are +// only ever won" stays literal, the league just deepens the pot: lasers join the duel ladder at +// league 3, rockets at 4, and league 5 duels pay DOUBLE tins. The world board is not devalued by +// starting inventory, and every weapon still arrives through resolveInterlude('P'). +export const TIN_LEAGUE = { laser: 3, rocket: 4 }; +export const TIN_DOUBLE_LEAGUE = 5; + // ------------------------------------------------------------------ courts ---- // A court type is pure DATA: adding one is a row here plus its four PNGs, never a change to render(). // Each carries TWO palettes because every court still rots — `tennis` is how it looks before the diff --git a/src/gameplay.js b/src/gameplay.js index 26a2a78..28e7a62 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 } 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, 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 } from './profile.js'; +import { award, beginRunXp, prof, bonus } 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 @@ -169,6 +169,11 @@ export const flatCount = (streak, aim) => (aim && Math.abs(swayOf(aim)) > 0.15 ? // 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 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 +// the suite: the ramp is what makes a late tin worth something again. +export const duelSkill = () => G.match ? 0 : Math.min(1, G.runGames / TUNE.duelRampGames); + function doHit(ch, quality, aim) { // aim: {ax: -1|0|1, az: -1|0|1} from held keys / flick at contact (az -1 = deep, 1 = short) const toFar = ch.side === 'P'; @@ -904,6 +909,11 @@ function spendSlot(i) { // (capped per type at TUNE.wCap) — old stock is kept, so you can bank a bomb for the horde // instead of spending it now. G.weapon picks which tin the next serve throws. const TINS = ['bomb', 'triple', 'fire', 'gun']; // the duel ladder, in the order it hands them out +// THE TIN TIERS (the leagues): the league deepens the pot the duel pays from — a barrel weapon +// joins the ladder when the RUN unlocks it (as ever) OR when the LEAGUE says it is yours +// (config.TIN_LEAGUE). Pure and exported so the suite can pin the pot. +export const tinLadder = () => [...TINS, ...Object.keys(BARREL_WEAPONS).filter(w => + G.runGames >= BARREL_WEAPONS[w].from || prof.lg >= (TIN_LEAGUE[w] || 99))]; export function deliverTin() { // WALK the ladder to the next rung you have room for, rather than cycling blindly onto one that // is already at wCap. Blind cycling meant `grantWeapon` clamped the top-up to a no-op and a won @@ -915,7 +925,7 @@ export function deliverTin() { // once all four tins hit wCap — and now that wCap is effectively no cap that branch would never // fire again, so a late duel could only ever hand you a fifth bomb. Unlocked ones only, so this // still cannot hand you a rocket in game three. - const ladder = [...TINS, ...Object.keys(BARREL_WEAPONS).filter(w => G.runGames >= BARREL_WEAPONS[w].from)]; + const ladder = tinLadder(); let id = null; for (let k = 0; k < ladder.length; k++) { const cand = ladder[(G.deliveries + k) % ladder.length]; @@ -925,14 +935,16 @@ export function deliverTin() { // of miming a handover that cannot happen. No boy, no umpire line — nothing was delivered. if (!id) { G.flash = { text: 'RACK FULL', t: 1.1 }; return; } const wasPlain = G.weapon === 0 && !G.temp; // never yank you off a deliberate PLAIN (banking) - grantWeapon(id); + // league 5 duels pay DOUBLE tins — the top of the club tips well (config.TIN_DOUBLE_LEAGUE) + const tinN = WEAPONS[id].tin * (prof.lg >= TIN_DOUBLE_LEAGUE ? 2 : 1); + grantWeapon(id, tinN); if (wasPlain) G.weapon = 0; sendBoy(); voice('new_balls', 0.6); aside('ump_new_balls_dark', 2.0); // "...and a new opponent, apparently." // THE PRIZE GETS ITS OWN CARD: the tin's picture, its name, and how many. Nothing else — the // first-sight MODAL is gone (a popup that freezes a live court mid-run costs runs) and so is the // prose that replaced it. You have just won a point; you want to know what landed and how much. - G.card = { title: WEAPONS[id].label, sub1: '×' + WEAPONS[id].tin, + G.card = { title: WEAPONS[id].label, sub1: '×' + tinN, icon: WEAPONS[id].icon, color: UI.warm, t: TUNE.tinCardT }; } @@ -1157,7 +1169,8 @@ function matchFallWillPlay() { // answer "how smart is this one" identically or the twist pulls in three directions at once. const learnK = rz => G.match && !G.net ? Math.min(1, Math.max(0, (G.match.opp - 1) / (LADDER - 1))) : 0; const rzChaseSpeed = rz => TUNE.racketSpeed * leaguePace() - * (G.match ? (TUNE.matchSpeedBase + (G.match.opp - 1) * TUNE.matchSpeedPer) * ((rz.foe && rz.foe.speed) || 1) : 1) + * (G.match ? (TUNE.matchSpeedBase + (G.match.opp - 1) * TUNE.matchSpeedPer) * ((rz.foe && rz.foe.speed) || 1) + : 1 + TUNE.duelSpeedMax * duelSkill()) * (G.wasServe && ball.lastHit === 'P' ? TUNE.aiReturnDash : 1) // wrong-footed: a body mid-lean is slow to turn — THE READ's one-ball cost, and it lives HERE // so the intercept planner and the reach tests believe it too (a surprised champion plans like @@ -1465,7 +1478,9 @@ function racketAI(rz, dt) { const learn = learnK(rz); const read = G.match && G.match.surprised ? TUNE.aiRead * (1 - TUNE.surpRead * learn) : G.match && G.match.expected ? TUNE.aiRead + (TUNE.readAnticipate - TUNE.aiRead) * learn - : TUNE.aiRead; + : G.match ? TUNE.aiRead + // the DUEL's read climbs with the run (never to 1 — wrong-footing stays possible) + : TUNE.aiRead + (TUNE.duelReadMax - TUNE.aiRead) * duelSkill(); const aimX = pred ? ball.x + (pred.x - ball.x) * read : ball.x; const aimZ = pred ? ball.z + (pred.z - ball.z) * read : ball.z; const tz = Math.max(1.5, Math.min(COURT.svcFar, aimZ)); // chase laterally, hold deep on the far court @@ -1506,13 +1521,14 @@ function racketAI(rz, dt) { // ...and the match has its OWN flub base: the duel's 0.2 hands a fifth of all returns away, // which across a game is an opponent that beats itself. const flub = (G.match ? TUNE.matchFlub * Math.pow(TUNE.matchFlubKeep, G.match.opp - 1) * (rz.foe?.flub || 1) * wnd.flub - : TUNE.racketFlub) + : TUNE.racketFlub * (1 - TUNE.duelFlubCut * duelSkill())) // THE READ: a wrong-footed racket reaches the ball late even when the legs got there — // the surprise ball ADDS whiff on top of the rung's own rate, scaled by how much this // body had actually learned. The champion's near-zero flub is exactly what this opens. + (G.match && G.match.surprised ? TUNE.surpFlub * learnK(rz) : 0); if (Math.random() < flub) rz.swingCool = 0.5; // whiff — it hesitates (rarer per rung of the ladder) - else { rz.swingT = TUNE.swingWindup + TUNE.swingWindow / 2; trySwing(rz, true, TUNE.racketQMax); rz.swingT = -1; rz.swingCool = 0.3; } + else { rz.swingT = TUNE.swingWindup + TUNE.swingWindow / 2; + trySwing(rz, true, TUNE.racketQMax + TUNE.duelQBonus * duelSkill()); rz.swingT = -1; rz.swingCool = 0.3; } // the lean is SPENT at the racket, either way — matchReady clears it when a point ends unmet if (G.match) G.match.surprised = G.match.expected = false; } @@ -1602,7 +1618,9 @@ export function dragServe(aim) { function meleeCooldown() { let near = 0; for (const zb of zombies) if (zb && Math.hypot(zb.x - P.x, zb.z - P.z) < TUNE.meleeCrowdR) near++; - return Math.max(TUNE.meleeCoolMin, TUNE.meleeCool / (1 + Math.max(0, near - 1) * TUNE.meleeCrowd)); + // the FLOOR rides the level (the leagues: the level is you) — the relaxed ceiling does not + return Math.max(TUNE.meleeCoolMin * bonus().melee, + TUNE.meleeCool / (1 + Math.max(0, near - 1) * TUNE.meleeCrowd)); } export function keyPressed(code) { @@ -1637,7 +1655,7 @@ export function keyPressed(code) { // aimed at a machine's shot tossed the serve ball instead and the return was, in practice, // impossible. Oliver could not land one. A hostile ball in the air is the most visible thing on the // screen, so "the tap is a swing at it" keeps one gesture meaning one thing at any given moment. - if (G.mode === 'splatter' && shots.some(s => Math.hypot(s.x - P.x, (s.z - P.z) * 0.8) < TUNE.parryArm)) { + if (G.mode === 'splatter' && shots.some(s => Math.hypot(s.x - P.x, (s.z - P.z) * 0.8) < TUNE.parryArm * bonus().parry)) { P.swingT = 0; P.pose = 'swing'; P.poseT = 0; G.swung = true; swish(0.3); return; diff --git a/src/main.js b/src/main.js index ce579c3..776d366 100644 --- a/src/main.js +++ b/src/main.js @@ -713,7 +713,7 @@ document.getElementById('nbSend').addEventListener('click', async () => { const btn = document.getElementById('nbSend'); btn.disabled = true; nbMsg.textContent = 'SENDING…'; // the double-tap posts twice otherwise try { - const r = await stats.submit(name, nbRun.score, nbRun.sub, nbRun.mode); + const r = await stats.submit(name, nbRun.score, nbRun.sub, nbRun.mode, profile.prof.lg); rkMode = nbRun.mode; // ...and the board that opens is the one you just wrote your name on for (const el of document.getElementById('rkModes').children) el.classList.toggle('on', el.dataset.m === rkMode); diff --git a/src/profile.js b/src/profile.js index b7f4360..73ab675 100644 --- a/src/profile.js +++ b/src/profile.js @@ -88,6 +88,17 @@ export function award(kind, n = 1) { return amt; } +// ---------------------------------------------------------------- the level is you ---- +// Phase 4's defensive buffs, through existing dials only: level 1 is the shipped game (factor 1) +// and the cap stays honest — a maxed profile survives volleys better, it does not stop dying. +// Read at the dial sites (meleeCooldown's floor, the parry's reach/arm, hurtPlayer's grace). +export function bonus() { + const f = (levelAt(prof.xp) - 1) / (LEVEL_CAP - 1); + return { melee: 1 - 0.25 * f, // the swish floor drops to 75% — panic scales with mastery + parry: 1 + 0.25 * f, // the parry reaches a quarter further + grace: 1 + 0.35 * f }; // a volley needs a third longer to land twice +} + // ---------------------------------------------------------------- the promotion ---- // A cup win that moved the league parks its ceremony here (beats.cupWon writes, main.maybeRestart // consumes once the summary is tapped away). Module-local and consumed-on-read, the same shape as diff --git a/src/stats.js b/src/stats.js index efd2e0b..5407f87 100644 --- a/src/stats.js +++ b/src/stats.js @@ -218,12 +218,14 @@ export const saveName = s => { // `k` is the BOARD's number and `g` its second column — what those mean is the mode's business // (BOARDS), and the server only ever sorts them. `m` is which board the row goes on. -export async function submit(rawName, k, g, mode = MODE_GORE) { +export async function submit(rawName, k, g, mode = MODE_GORE, lg = 1) { const name = saveName(rawName); if (!name) throw new Error('empty name'); + // `lg` is the league badge the row will wear (THE LEAGUES) — context beside the score, never a + // sort key. Passed in by the caller because profile.js imports THIS module. const r = await fetch(API + 'scores', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ n: name, k, g, m: mode, id: myId() }) }); + body: JSON.stringify({ n: name, k, g, m: mode, id: myId(), lg } ) }); if (r.status === 409) { const e = new Error('name taken'); e.taken = true; throw e; } if (!r.ok) throw new Error('submit ' + r.status); return r.json(); // {ok, rank, scores} @@ -322,7 +324,11 @@ export async function drawRanks(rows, mode = MODE_GORE) { if (!rows.length) { el.innerHTML = '

NOBODY IS ON THIS BOARD YET. BE FIRST.

'; return; } const me = savedName(); el.innerHTML = rows.map((r, i) => - `
${n(i + 1)}${esc(r.n)}` + `
${n(i + 1)}` + // the league badge, when the row has one: CONTEXT beside the name — a league-5 high score is + // expected, a league-2 upset is visible. Old rows carry none and show none. + + (r.lg >= 1 && r.lg <= 5 ? `` : '') + + `${esc(r.n)}` + `${n(r.k || 0)} · ${r.g || 0}
`).join(''); } diff --git a/src/zombies.js b/src/zombies.js index 0d969ae..c37be76 100644 --- a/src/zombies.js +++ b/src/zombies.js @@ -11,7 +11,7 @@ import { drillKill, drillCaught } from './drill.js'; import { splatPoint, cheer, spentBall, aside, grantWeapon, endBurst, sliceCarom } from './gameplay.js'; import { resolveInterlude, endBossFight } from './beats.js'; // runtime-only cycle, like the gameplay one import { tally, multiKill, endRun, MODE_MATCH } from './stats.js'; -import { award } from './profile.js'; +import { award, bonus } from './profile.js'; export const zombies = []; // {x, z, v, walkT, variant: 'a'|'b'|'c'} export const gore = []; // lawn decals {x, z, r, a} — the flat ground layer @@ -267,7 +267,7 @@ export function hurtPlayer(n) { // it beats this channel and the eat test below, which is the only reason to spend a dodge. if (G.mode !== 'splatter' || G.hurtT > 0 || G.dodgeI > 0 || G.mode === 'eaten') return; G.hp -= n; - G.hurtT = TUNE.hurtGrace; G.hurtFlash = 0.3; + G.hurtT = TUNE.hurtGrace * bonus().grace; G.hurtFlash = 0.3; // the grace rides the level (the leagues) haptic('heavy'); // taking one is rare (hurtGrace gates it) and is the loudest thing the hand gets G.shake = { t: 0.22, mag: 4 }; splat(0.7); aww(0.5); @@ -1383,14 +1383,14 @@ export function updateZombies(dt) { const dP = Math.hypot(s.x - P.x, (s.z - P.z) * 0.8); const dHit = Math.hypot(s.x - P.x, s.z - P.z); if (!done && P.swingT >= 0 && P.swingT <= TUNE.parryWindow - && dP < TUNE.parryReach && s.y < CONTACT.maxY) { + && dP < TUNE.parryReach * bonus().parry && s.y < CONTACT.maxY) { // the parry: it becomes an `extras` clone — that array is already ballistic and already kills // zombies, so "returned shot is a live weapon" costs nothing to build. // It is a GLANCING blow, not a mirror. Reversing the incoming line sent every return straight // back up the line it arrived on — which is where the thrower is standing — so a boss was a // free wound per throw. Where you meet the ball across the racket face now sets the angle: // hitting it back INTO him is a shot you have to place your feet for. - const off = Math.max(-1, Math.min(1, (s.x - P.x) / TUNE.parryReach)); // -1..1 across the face + const off = Math.max(-1, Math.min(1, (s.x - P.x) / (TUNE.parryReach * bonus().parry))); // -1..1 across the face const ang = (off * TUNE.parryDeflect + (Math.random() * 2 - 1) * TUNE.parryJitter) * Math.PI / 180; const sp = Math.hypot(s.vx, s.vz) * 1.15; extras.push({ x: s.x, y: s.y, z: s.z, g: TUNE.gravity, pow: TUNE.parryPow, t: 2.5, diff --git a/test.js b/test.js index f6c1b59..c45fa01 100644 --- a/test.js +++ b/test.js @@ -89,13 +89,13 @@ 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, 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, 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 // player would, so the tests step a live court const startDrill = (...a) => { const r = _startDrill(...a); G.modal = null; G.modalQ.length = 0; return r; }; -const { zombies, spawnZombie, pellets, parts, explode } = await import('./src/zombies.js'); +const { zombies, spawnZombie, pellets, parts, explode, hurtPlayer } = await import('./src/zombies.js'); const { killPlayer } = await import('./src/zombies.js'); const { drillQueue, drillLegal, arrival, drillMult, DRILL_TYPES } = await import('./src/drill.js'); const cfg = await import('./src/config.js'); @@ -2268,5 +2268,67 @@ test('THE LEAGUES: the pace tier scales the solo match world and nothing else', G.league = 1; }); +test('THE DUEL LEARNS: its skill ramps with the run, and game zero is the shipped duel', () => { + G.match = null; + G.runGames = 0; + assert.equal(duelSkill(), 0, 'a fresh run duels the shipped opponent'); + G.runGames = Math.round(TUNE.duelRampGames / 2); + const mid = duelSkill(); + G.runGames = TUNE.duelRampGames + 10; + assert.ok(mid > 0 && mid < 1, 'the ramp is a ramp'); + assert.equal(duelSkill(), 1, '...and it caps'); + G.match = { opp: 5 }; + assert.equal(duelSkill(), 0, 'the match opponent has its own ladder — the ramp is duel-only'); + G.match = null; + // the dial relationships the ramp promises: a full-skill duel still misses and still misreads + assert.ok(TUNE.racketFlub * (1 - TUNE.duelFlubCut) > 0.05, 'a maxed duel still whiffs sometimes'); + assert.ok(TUNE.duelReadMax < 1, 'a maxed duel can still be wrong-footed'); + assert.ok(TUNE.racketQMax + TUNE.duelQBonus < 1, 'its returns never reach perfect quality'); +}); + +test('THE TIN TIERS: the league deepens the duel pot, and league 5 pays double', () => { + G.runGames = 0; + prof.prof.lg = 1; + assert.ok(!tinLadder().includes('laser') && !tinLadder().includes('rocket'), + 'league one, game zero: the shipped pot'); + prof.prof.lg = 3; + assert.ok(tinLadder().includes('laser') && !tinLadder().includes('rocket'), + 'league three adds the laser, not the rocket'); + prof.prof.lg = 4; + assert.ok(tinLadder().includes('rocket'), 'league four adds the rocket'); + prof.prof.lg = 1; + G.runGames = 12; + assert.ok(tinLadder().includes('laser'), 'the run\'s own barrel gates still work without a league'); + G.runGames = 0; + // the double tin: an empty rack, one delivery, twice the stock at league five + const stockOf = id => { const i = G.rack.findIndex(r => r.id === id); return i < 0 ? 0 : G.rack[i].n; }; + G.rack.length = 0; G.temp = null; G.deliveries = 0; + prof.prof.lg = 5; + deliverTin(); + const first = G.rack[0]; + assert.equal(first.n, cfg.WEAPONS[first.id].tin * 2, 'a league-five duel pays a double tin'); + G.rack.length = 0; G.temp = null; G.deliveries = 0; + prof.prof.lg = 1; + deliverTin(); + assert.equal(G.rack[0].n, cfg.WEAPONS[G.rack[0].id].tin, 'league one pays the shipped tin'); +}); + +test('THE LEVEL IS YOU: the defensive buffs scale with level and level one is the shipped game', () => { + const xp0 = prof.prof.xp; + prof.prof.xp = 0; + const b1 = prof.bonus(); + assert.deepEqual(b1, { melee: 1, parry: 1, grace: 1 }, 'level one changes nothing'); + prof.prof.xp = prof.xpFor(prof.LEVEL_CAP); + const bMax = prof.bonus(); + assert.ok(bMax.melee < 1 && bMax.melee >= 0.7, 'the swish floor drops, boundedly'); + assert.ok(bMax.parry > 1 && bMax.parry <= 1.3, 'the parry reaches further, boundedly'); + assert.ok(bMax.grace > 1 && bMax.grace <= 1.4, 'the grace stretches, boundedly'); + // ...and the grace actually lands on the player: hurtPlayer writes the scaled window + G.mode = 'splatter'; G.hurtT = 0; G.dodgeI = 0; G.hp = 4; + hurtPlayer(1); + assert.ok(G.hurtT > TUNE.hurtGrace * 1.3, 'a maxed profile\'s grace window is visibly longer'); + prof.prof.xp = xp0; +}); + console.log(`\n${passed} tests passed`); process.exit(0); // render.js schedules viewport timers at load; don't let them outlive the report