diff --git a/PROJECT.md b/PROJECT.md index 598f350..f98d1c7 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -434,6 +434,26 @@ vetoed (*"the camera movement is distracting"*) and its knobs sit at 0 — see t (~0.35ms/frame desktop, settled key at both ends) — if the phone stutters in rallies, zero these two before touching anything else. +## Session log: THE TOURNAMENT (2026-08-13, same day — Oliver picked it over the endless ladder) + +The fourth mode tile: four rounds up the dead hierarchy (`config.CUP_ROUNDS` — each round DRAWS a +rung from its band, the final is always THE CHAMPION), riding the whole match machinery and +differing in exactly three places: who walks on (`cupDraw` puts the drawn rung in `d.opp`, so every +ladder dial downstream scales untouched), how deep the body's pool is (`matchOpponent` multiplies +by the round's `need` and `matchPoint` divides the same way, so wound stages SPREAD across the +round — the body enters game two missing what game one cost it), and where a won game goes +(`cupGameWon`: mid-round the same body plays on; round won is the kill + mop beat + next draw; +past the final, `cupWon` banks a NOT-dead match run and holds CHAMPION OF THE DEAD CLUB on the +classic win's summary shape). Blood carries; the trainer's heal-per-game is the distance. Round +one is a single game so a run opens fast; deathmatch stays beside it as the arcade mode. + +Also this session: the fall ANTICIPATES the pass (a winner past the net with a closed-form +unreachable landing resolves mid-flight — plus the soft-lock the first version shipped: the +decided-latch must die with its rally, see the beats/gameplay comments); the fall table lost its +rise row and tightened its drop; the ghost trail is PACE-qualified so both tennis modes streak +(`trailSpeed`); and opponent sprites draw brighter via a load-time baked copy +(`render.brighten`, `TUNE.oppBright`) — no PNG touched. + ## Session log: the serve gets stakes (2026-08-13, same day) Oliver: *"the golden mark is just very easy to hit and hitting below it does not necessarily make a diff --git a/index.html b/index.html index 5d6d89f..9c40838 100644 --- a/index.html +++ b/index.html @@ -431,6 +431,6 @@ if (location.hostname !== 'splatter-tennis.wuwi.one') { - + diff --git a/src/beats.js b/src/beats.js index 7d89d45..e8f43df 100644 --- a/src/beats.js +++ b/src/beats.js @@ -7,7 +7,8 @@ // (beats <-> gameplay and beats <-> zombies are runtime-only import cycles, same pattern as // input <-> gameplay: the imported bindings are only touched inside functions, never at load.) -import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage } from './config.js'; +import { TUNE, UI, bossArt, matchFoe, BENCH, ZFAMS, COURT, HOME, racketStage, CUP_ROUNDS } from './config.js'; +import { endRun, MODE_MATCH } from './stats.js'; import { G, P, O, ball, score, setupPoint, sendBoy, other } from './state.js'; import { voice, zombieDuck, aww } from './audio.js'; import { zombies, pellets, extras, shots, spawnRacketZombie, spawnChapterBoss, spawnZombie, explode, killPlayer } from './zombies.js'; @@ -320,9 +321,14 @@ export function watchdog(dt) { // the match mode ends. Regicide is inherited for free: smashing the ball into the body still wounds // it, and killing it outright wins the game early (killZombie fires resolveInterlude('P'), which // lands in matchGameWon via the empty-court check). -export function startMatch() { +export function startMatch(cup = false) { if (G.interlude || G.horde || G.bossFight || G.drill || G.match || G.bench) return false; G.match = { opp: 1, ending: false }; + // 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 + // by the round's `need`), and where a won game goes (cupGameWon). See config.CUP_ROUNDS. + if (cup) { G.match.cup = { round: 0, g: 0 }; cupDraw(); } G.matchFall = null; G.hp = G.hpMax = TUNE.matchPlayerHp; // a real match is longer than four mistakes — the gore // mode's 4 was "too weak" here, felt not calculated @@ -335,7 +341,12 @@ export function startMatch() { matchReady(); zombieDuck(true); voice('play_resume', 0.2); - if (!G.seen.match) { + if (cup && !G.seen.cup) { + G.seen.cup = true; + showModal({ title: 'THE TOURNAMENT', color: UI.hot, + body: ['FOUR ROUNDS UP THE DEAD HIERARCHY', + 'ITS POINTS DRAW BLOOD — ' + TUNE.matchPlayerHp + ' AND YOU ARE EATEN'] }); + } else if (!cup && !G.seen.match) { G.seen.match = true; // Oliver's copy direction: name the stake, count the blood. TUNE.matchPlayerHp is the real // number, so the modal can never drift from the pool it describes. @@ -346,6 +357,14 @@ export function startMatch() { return true; } +// The round's rung, drawn from its band. d.opp carries it so matchFoe, the ladder speed dials and +// the felled arithmetic all read the drawn rung with no cup-awareness of their own. +function cupDraw() { + const cup = G.match.cup, R = CUP_ROUNDS[cup.round]; + cup.rung = R.band[Math.floor(Math.random() * R.band.length)]; + G.match.opp = cup.rung; +} + // Every point starts like a real one: the server behind his line (setupPoint's tennis branch) and // THE RECEIVER ON ITS MARK — the zombie is dropped onto the receiver position the tennis branch // just computed for O, instead of standing wherever the last rally abandoned it. Oliver: "it spawns @@ -530,12 +549,17 @@ function matchOpponent() { zb.fam = zb.foe.fam; // ...and the body it wears (config.ZFAMS) // the walk-on card wears the opponent's own head (config.HEAD_CROP — a crop, not new art). // 4.0 s: at 2.4 the head + name + punchline were gone before they were read ("the intro card - // of the player should last longer") - G.card = { title: zb.foe.name, sub1: zb.foe.line, color: UI.hot, t: 4.0, head: zb.fam }; - zb.hp = zb.hpMax = TUNE.matchOppHp; // a FLAT pool: with regicide gone the body is pure - // scoreboard (a quarter per point), so a deeper pool would - // change nothing — the ladder escalates in racketAI instead - // (matchSpeedPer / matchFlubKeep) + // of the player should last longer"). In the cup the round names itself on the third line. + const cup = G.match.cup; + G.card = { title: zb.foe.name, sub1: zb.foe.line, color: UI.hot, t: 4.0, head: zb.fam, + sub2: cup ? CUP_ROUNDS[cup.round].name + (CUP_ROUNDS[cup.round].need > 1 + ? ' · FIRST TO ' + CUP_ROUNDS[cup.round].need + ' GAMES' : '') : undefined }; + // a FLAT pool per body: with regicide gone the body is pure scoreboard (matchPoint takes a + // game's-quarter per point), so a deeper pool would change nothing — the ladder escalates in + // racketAI instead (matchSpeedPer / matchFlubKeep). THE CUP MULTIPLIES BY THE ROUND'S NEED and + // matchPoint divides the same way, so the wound stages spread across the whole round instead of + // being spent in game one — the body enters game two already missing what game one cost it. + zb.hp = zb.hpMax = TUNE.matchOppHp * (cup ? CUP_ROUNDS[cup.round].need : 1); G.interlude = { introT: 1.6 }; // the duel machinery IS the match machinery } @@ -560,7 +584,8 @@ function matchPoint(side) { // and only the game-winning point actually fells it, so the body and the scoreboard agree all // the way to the end. (Ball hits wound it on top of this; that is the regicide path, kept.) const was = racketStage(zb); - zb.hp = Math.max(1, zb.hp - Math.ceil(zb.hpMax / 4)); + const div = 4 * ((d.cup && CUP_ROUNDS[d.cup.round].need) || 1); // the cup spreads the pool over the round + zb.hp = Math.max(1, zb.hp - Math.ceil(zb.hpMax / div)); // ONLY when the stage actually moved. A point that chips the pool without crossing a threshold // has no limb to explain, and a second of collapse for nothing is a beat the player waits out. if (racketStage(zb) !== was) fellFrom = was; @@ -592,6 +617,7 @@ function matchPoint(side) { function matchGameWon(zb, banked = false) { const d = G.match; + if (d.cup) return cupGameWon(zb, banked); d.ending = true; // killZombie below re-enters resolveInterlude if (zb && zombies.includes(zb)) explode(zb.x, zb.z); // the game-winning point IS the kill // REGICIDE arrives here without advancePoint having banked anything (the body died mid-game, not @@ -638,6 +664,62 @@ function matchGameWon(zb, banked = false) { G.phase = 'over'; G.overT = d.mopT + 2.2; // ticks down through the beat; reset at the walk-on } +// THE CUP'S GAME. Mid-round: the same body plays on, wearing what the games have cost it. Round +// won: the kill, the mop beat, and the next round's draw — or, past the final, the trophy. +function cupGameWon(zb, banked = false) { + const d = G.match, cup = d.cup, R = CUP_ROUNDS[cup.round]; + d.ending = true; + if (!banked) { score.games[0]++; score.pts = [0, 0]; } + G.runGames++; + const healed = Math.min(G.hpMax, G.hp + TUNE.matchHeal) - G.hp; + G.hp += healed; + if (healed > 0) + showModal({ title: 'WOUNDS TREATED', icon: 'x_cross', iconH: 52, color: UI.pick, + body: ['THE TRAINER PATCHED YOU UP', G.hp + ' OF ' + G.hpMax + ' BACK'] }); + voice('game_player', 0.3); cheer(2.4, 0.6); + cup.g++; + if (cup.g < R.need) { + // the round is still alive: no kill, no mop beat — the body keeps its wounds and its feet + G.flash = { text: 'GAME.', t: 1.4 }; + G.card = { title: R.name, sub1: 'YOU LEAD ' + cup.g + ' OF ' + R.need + ' — SAME BODY, FEWER PIECES', + color: UI.pick, t: 3.0 }; + score.server = other(score.server); + d.ending = false; + matchReady(); + G.phase = 'over'; G.overT = 2.2; + return; + } + // the round is won: the corpse comes apart + if (zb && zombies.includes(zb)) explode(zb.x, zb.z); + cup.round++; cup.g = 0; + if (cup.round >= CUP_ROUNDS.length) return cupWon(); + G.flash = { text: R.name + ' — YOURS.', t: 1.8 }; + // the mop beat, exactly as the deathmatch runs it: the boy owns the court, then the next draw + sendBoy(0, 4.5, true); + if (G.boy && G.boy.mop) { G.boy.jobs = TUNE.boyMaxJobs; d.mopT = TUNE.matchMopT; } + else d.mopT = 0.6; + G.interlude = { introT: d.mopT + 1.6 }; + cupDraw(); // the next round's rung -> d.opp + score.server = other(score.server); + G.phase = 'over'; G.overT = d.mopT + 2.2; // d.ending stays true until the walk-on tick +} + +// CHAMPION OF THE DEAD CLUB — the run's other ending, on the classic win's whole shape (a held +// summary over a still court, then the gated restart). Banked through the same endRun as every +// match run, not dead: the stats screen's deaths column keeps meaning what it means. +function cupWon() { + const d = G.match; + d.ending = false; d.mopT = 0; + G.interlude = false; + ball.active = false; G.flash = null; G.modal = null; G.card = null; + cheer(4, 1); voice('unbelievable', 0.4); + const games = score.games[0] + score.games[1]; + const b = endRun(CUP_ROUNDS.length, games, G.splatT, false, MODE_MATCH); + G.best = b; G.newBest = b.newBest; + d.felled = CUP_ROUNDS.length; d.games = games; d.best = b.games; + G.done = { won: true, t: 0, cup: true }; +} + function matchGameLost() { // Reachable only through deuce (four straight points is exactly your hp) — the body keeps its // wounds, the scoreboard banks their game, and the match simply plays on. diff --git a/src/config.js b/src/config.js index 793b8e3..9b43c84 100644 --- a/src/config.js +++ b/src/config.js @@ -46,6 +46,10 @@ export const TUNE = { faceFlip: 0.13, // s an auto-steered direction must persist before the body mirrors (earnFlip). // The rolled cycles hold a real racket, and an un-earned flip teleports it // across the body — the "racket flashing left and right" report + trailSpeed: 16, // m/s of horizontal pace at which a rally ball earns the ghost streak in every + // mode (the pow>0.8 gate alone was only ever reached by the gore slingshot) + oppBright: 1.18, // brightness baked into every OPPONENT sprite copy at load (render.brighten) — + // the far bodies read dark against the crowd. Load-time: reload to retune serveSpin: 1.3, // extra gravity a full-power serve carries (was a fixed 0.35). Without it the // net-clearance loop ate the whole smash: a flat fast ball can't get over the // tape AND come down on a target, so it got throttled back to walking pace. @@ -630,9 +634,24 @@ export const COURTS = [ export const MODES = [ { id: 'classic', name: 'CLASSIC TENNIS', sub: 'REAL TENNIS · BEST OF THREE SETS', tile: 'o_swing1' }, { id: 'zombie', name: 'ZOMBIE TENNIS', sub: 'ONE GAME PER OPPONENT · BLOOD ONLY', tile: 'z_r1' }, + { id: 'cup', name: 'THE TOURNAMENT', sub: 'FOUR ROUNDS · THE CHAMPION WAITS', tile: 'z_champ1' }, { id: 'gore', name: 'SPLATTER GORE', sub: 'THE MATCH TURNS · SURVIVE IT', tile: 'z_boss1' }, ]; +// THE TOURNAMENT (Oliver's pick over the endless ladder, 2026-08-13): four rounds up the club's +// dead hierarchy, each round DRAWING one rung from its band so runs differ, the final always THE +// CHAMPION. `need` is games to take the round — round one is a single game so a run opens fast — +// and the drawn body's pool scales with need (see matchOpponent), so its wound stages spread +// across the whole round instead of being spent in game one. Blood carries between rounds; the +// trainer's heal-per-game is what makes the distance survivable. Deathmatch (id 'zombie') stays +// beside it as the arcade run. +export const CUP_ROUNDS = [ + { name: 'ROUND ONE', band: [1, 2, 3], need: 1 }, + { name: 'QUARTER-FINAL', band: [4, 5, 6], need: 2 }, + { name: 'SEMI-FINAL', band: [7, 8, 9, 10, 11], need: 2 }, + { name: 'THE FINAL', band: [12], need: 2 }, +]; + export const TOURS = [ { id: 'men', name: "MEN'S SINGLES", tile: 'tour_m', sfx: null }, { id: 'women', name: "WOMEN'S SINGLES", tile: 'tour_w', sfx: '_f', dx: 0.46, apex: 0.78, tossDx: -0.59 }, diff --git a/src/main.js b/src/main.js index 50e535d..344074d 100644 --- a/src/main.js +++ b/src/main.js @@ -177,6 +177,7 @@ function start(modeId = pickedMode) { // lives in the code that reads G.classic / G.match, never here. G.classic = modeId === 'classic'; if (modeId === 'zombie') { intoSplatter(); startMatch(); } + if (modeId === 'cup') { intoSplatter(); startMatch(true); } } let stPeriod = 'all', stMode = stats.MODE_GORE; // the stats screen opens on the mode you last PICKED to play — that is the one whose numbers you @@ -459,6 +460,7 @@ if (location.hash === '#laser') setTimeout(laserScene, 600); // #drill — the same door from a URL, so a phone can be sent straight into the mode if (location.hash === '#drill') setTimeout(() => { start(null); intoSplatter(); startDrill(); }, 700); if (location.hash === '#match') setTimeout(() => { start(null); intoSplatter(); startMatch(); }, 700); +if (location.hash === '#cup') setTimeout(() => { start(null); intoSplatter(); startMatch(true); }, 700); if (location.hash === '#bench') setTimeout(() => { start(null); intoSplatter(); startBench(); }, 700); requestAnimationFrame(frame); diff --git a/src/render.js b/src/render.js index 162359d..28084f4 100644 --- a/src/render.js +++ b/src/render.js @@ -1,7 +1,7 @@ // Camera, viewport, sprites and all drawing. Owns the crowd dots (visual-only state). import { cv, ctx } from './dom.js'; -import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS, HEAD_CROP } from './config.js'; +import { BENCH, BENCH_STAGES, ZFAMS, famFrames, racketStage, TUNE, UI, EATEN_BURST, EATEN_SUMMARY, EATEN_RESTART, DONE_RESTART, COURT, P_BOUNDS, PLAYER_H, BOY_H, UMP_H, UMP_X, MACH_H, BOSS_ART, ZWOUND, woundStage, BOUNCE_IDLE, SPRITE_H, zombieH , WEAPONS, RACK_ROW, TOSS, HEAD_CROP, CUP_ROUNDS } from './config.js'; import { P, O, ball, score, crowd, G, court, tour } from './state.js'; import { predictLanding, previewServe, slotId, slotStock, serveApexY } from './gameplay.js'; import { drillQueue, drillLegal, drillMult } from './drill.js'; @@ -309,11 +309,35 @@ for (const n of ['p_idle1','p_idle2','p_bounce','p_bounce2','p_toss','p_smash',' // second failure, or one genuinely missing file hangs the boot bar forever. let retried = false; LOAD.total++; - img.onload = () => { SPR[n] = img; LOAD.done++; }; + img.onload = () => { SPR[n] = brighten(n, img); LOAD.done++; }; img.onerror = () => { if (retried) LOAD.done++; else { retried = true; img.src = url + '&r=1'; } }; img.src = url; } +// OPPONENT BODIES DRAW BRIGHTER, without touching a single PNG (Oliver: "make the opponent +// sprites brighter in game — filter or something — without changing the spritesheet files"). +// Baked ONCE at load into an offscreen copy: an additive self-blend at alpha (b-1) is an exact +// brightness multiply with no per-pixel loop and no per-frame ctx.filter (which iOS Safari's +// canvas doesn't reliably support). Covers every far-court body — the zombie families and the +// tennis CPU — and leaves the player, the boys, the furniture and the HUD art untouched. +// TUNE.oppBright is read at LOAD, so retuning it needs a reload, not just the panel. +function brighten(n, img) { + if (!(n[0] === 'z' && n[1] === '_') && !(n[0] === 'o' && n[1] === '_')) return img; + const b = TUNE.oppBright || 1; + if (b === 1) return img; + const c = document.createElement('canvas'); + c.width = img.width; c.height = img.height; + const g = c.getContext('2d'); + g.drawImage(img, 0, 0); + g.globalCompositeOperation = 'lighter'; + g.globalAlpha = b - 1; + g.drawImage(img, 0, 0); + g.globalCompositeOperation = 'destination-in'; // re-clip: additive blending fattened the alpha + g.globalAlpha = 1; + g.drawImage(img, 0, 0); + return c; +} + // ------------------------------------------------------------------ crowd ---- function buildCrowd() { crowd.dots.length = 0; @@ -2109,10 +2133,12 @@ export function render() { const by = Math.abs(Math.sin(G.idleT * BOUNCE_IDLE.tempo)) * (mine ? bounceApex() : BOUNCE_IDLE.oApex); drawBall(bx, by, sv.z); } - // hot-ball ghost streak: a rally ball above 0.8 pow drags three fading ghosts behind it. Serves - // already have the erasing arc band; this gives the volley its own read. Render-owned visual - // state (like the crowd dots) — gameplay never sees it. - const hot = ball.active && (ball.pow || 0) > 0.8 && G.phase === 'rally' && !ball.collected; + // hot-ball ghost streak: a fast rally ball drags three fading ghosts behind it. It gated on + // pow > 0.8 alone, which in practice only the gore slingshot ever reached — tennis tap swings + // live below it, so both tennis modes played without the streak (Oliver's sidequest). PACE now + // qualifies a ball too: a flat serve or a clean winner streaks in every mode, a lob stays clean. + const hot = ball.active && G.phase === 'rally' && !ball.collected + && ((ball.pow || 0) > 0.8 || Math.hypot(ball.vx, ball.vz) > TUNE.trailSpeed); if (hot) { ballHist.push({ x: ball.x, y: ball.y, z: ball.z }); if (ballHist.length > 7) ballHist.shift(); } else if (ballHist.length) ballHist.length = 0; if (hot) { @@ -2337,7 +2363,10 @@ export function render() { // prints those instead. Same slot, same size — the mode changes the words, not the furniture. // ...and in CLASSIC it says how far through the MATCH you are, which is the one thing the // board itself cannot: which set this is, and how many it takes to win. - ctx.fillText(G.match ? `OPPONENT ${G.match.opp} · ${G.match.opp - 1} FELLED` + // the cup names the ROUND — its opp is a drawn rung, so the deathmatch arithmetic lied here + ctx.fillText(G.match && G.match.cup + ? `${CUP_ROUNDS[G.match.cup.round].name} · ${G.match.cup.round} FELLED` + : G.match ? `OPPONENT ${G.match.opp} · ${G.match.opp - 1} FELLED` : G.classic ? `SET ${G.sets[0] + G.sets[1] + 1} · FIRST TO ${TUNE.classicSets}` : `GAME ${G.runGames + 1} · ${G.sets[0]} SET${G.sets[0] === 1 ? '' : 'S'}`, bW - 3, bH + 8); } @@ -2354,7 +2383,7 @@ export function render() { ctx.restore(); ctx.textBaseline = 'top'; ctx.textAlign = 'center'; if (!G.bench) drawBlood(); // how many more hits you have left, beside the board — the vignette says "worse", not "how many" - if (G.mode === 'splatter' && !G.interlude && !G.drill && !G.bench) drawRack(); // courtside weapon rack (hidden during the tennis-rules duel AND the drill, which is played with plain balls) + if (G.mode === 'splatter' && !G.interlude && !G.drill && !G.bench && !G.done) drawRack(); // ...and never under a trophy screen // courtside weapon rack (hidden during the tennis-rules duel AND the drill, which is played with plain balls) drawDrillHud(); // the triage readout — no-op unless a drill is running drawBenchHud(); // ...and the sprite bench's, same rule drawApproachRing(); drawApexBand(); // the match's timing, drawn — no-ops unless a match is running @@ -2376,10 +2405,12 @@ export function render() { ctx.fillStyle = won ? 'rgba(6,10,8,0.72)' : 'rgba(10,4,6,0.72)'; ctx.fillRect(0, 0, W, H); // The announcement face, at 26 rather than the card's 30: this is the longest string in the // game and at 30 it ran edge to edge on a 270px-wide portrait canvas. - const title = dead ? (m ? 'BLED OUT.' : 'GAME, SET, MASSACRE.') : won ? 'GAME, SET, MATCH.' : 'BEATEN.'; + const title = dead ? (m ? 'BLED OUT.' : 'GAME, SET, MASSACRE.') + : G.done.cup ? 'CHAMPION OF THE DEAD CLUB.' : won ? 'GAME, SET, MATCH.' : 'BEATEN.'; const line = dead ? (m ? 'FELLED ' + m.felled + ' OPPONENT' + (m.felled === 1 ? '' : 'S') + ' · ' + m.games + ' GAMES' : 'SURVIVED ' + G.runGames + ' GAMES · ' + G.sets[0] + ' SETS') + : G.done.cup ? 'THE CHAMPION FELL · ' + ((G.match && G.match.games) || G.runGames) + ' GAMES' : G.sets[0] + '–' + G.sets[1] + ' IN SETS · ' + G.runGames + ' GAMES'; ctx.font = B(26); shadowText(title, W / 2, H / 2 - 44, won ? UI.pick : UI.bad); ctx.font = F(9); shadowText(line, W / 2, H / 2 - 14, UI.cream); @@ -2422,7 +2453,7 @@ export function render() { // to aim, which is the one thing that does nothing there. // after a beat of inaction the apron demo takes over, and it says PULL DOWN in its own words — // so this slot shuts up rather than printing the same instruction a second time 40 px below it - const demo = G.mode === 'splatter' && !G.interlude && !gunLike && G.idleHintT > TUNE.pullHintAfter; + const demo = G.mode === 'splatter' && !G.interlude && !G.done && !gunLike && G.idleHintT > TUNE.pullHintAfter; // NO GHOST HAND ON THE TENNIS SERVE — not the idle tap demo, not the apex rider. Oliver: "this // ghost finger over the running serve animation is distracting. this must be removed. it is // not in zombie tennis mode" — and the duel serving fine without a teacher is the argument. diff --git a/src/zombies.js b/src/zombies.js index 26f9649..94f7d79 100644 --- a/src/zombies.js +++ b/src/zombies.js @@ -306,7 +306,8 @@ export function killPlayer() { // record's two headline slots (see the shape note there), so the best is derived from the record // rather than stored a second time in its own key and left to drift. const m = G.match; - const felled = m ? m.opp - 1 : 0, gamesPlayed = score.games[0] + score.games[1]; + // the cup's opp is a DRAWN RUNG, not a counter — bodies felled there is completed rounds + const felled = m ? (m.cup ? m.cup.round : m.opp - 1) : 0, gamesPlayed = score.games[0] + score.games[1]; const b = m ? endRun(felled, gamesPlayed, G.splatT, true, MODE_MATCH) : endRun(G.runGames, G.sets[0], G.splatT); G.best = b; G.newBest = b.newBest; // b is {games, sets, newBest}; render reads games/sets diff --git a/test.js b/test.js index a9208ef..9ad7f9d 100644 --- a/test.js +++ b/test.js @@ -811,6 +811,31 @@ test('an ordinary far winner keeps its ordinary ending, and the match plays on ( assert.equal(score.pts[0], pts + 2, 'the match plays on'); }); +test('THE TOURNAMENT: bands draw, bodies deepen, rounds hold their body, the champion ends it', () => { + const { CUP_ROUNDS } = cfg; + _startMatch(true); G.modal = null; G.modalQ.length = 0; + G.mode = 'splatter'; + const d = G.match; + assert.ok(d.cup, 'the cup rides the match machinery'); + for (let round = 0; round < CUP_ROUNDS.length; round++) { + const R = CUP_ROUNDS[round]; + assert.equal(d.cup.round, round, 'on ' + R.name); + assert.ok(R.band.includes(d.opp), R.name + ' drew from its band (rung ' + d.opp + ')'); + const zb = oppOf(); + assert.equal(zb.hpMax, TUNE.matchOppHp * R.need, 'the pool spans the whole round'); + for (let g = 1; g <= R.need; g++) { + for (let k = 0; k < 4; k++) resolveInterlude('P'); + G.modal = null; G.modalQ.length = 0; // the trainer's popup, tapped away + if (g < R.need) + assert.ok(zombies.includes(zb), R.name + ' game ' + g + ': the same body plays on'); + } + assert.ok(!zombies.includes(zb), R.name + ': the round-winning game is the kill'); + if (round < CUP_ROUNDS.length - 1) passMopBeat(); // the boy, then the next draw walks on + } + assert.ok(G.done && G.done.cup && G.done.won, 'the champion fell: the trophy screen holds'); + assert.equal(G.match.felled, CUP_ROUNDS.length, 'four bodies felled, banked as a match run'); +}); + test('service alternates by game, and the opponent actually serves', () => { // Until now the corpse only ever returned: score.server was pinned to 'P' because the match calls // advancePoint directly and the flip lives in tennis's pointTo, which the match bypasses. Return