From 1b8896e6fe2732a2ce6885558933044355281768 Mon Sep 17 00:00:00 2001 From: Alexandre Sakr Date: Mon, 6 Jul 2026 13:08:01 -0400 Subject: [PATCH] =?UTF-8?q?balance(doomsday-clock):=20reshape=20wave=20cad?= =?UTF-8?q?ence=20=E2=80=94=20flatten=20the=20front,=20steepen=20the=20end?= =?UTF-8?q?game?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-wave ramps/pauses (was a single uniform ramp/pause for all six waves) so the required-share curve can be SHAPED, not just paced. Why (OFM 2026 Summer data): under the uniform cadence ~75% of eliminations fell in the first half while the last third saw only ~7% — a rushed early game (worst in big lobbies, where it disproportionately culled weaker/newer players) followed by a drawn-out endgame stalemate. 85% of deaths are doom-driven, so the curve shape is the lever. Change: - Early waves are LOWER and come LATER (grace + longer early ramps) — more breathing room before the first cull. - Late waves are STEEPER with no pauses — a continuous endgame squeeze that breaks the stalemate and ends games sooner (fast: 55% at ~21:00 vs 24:00). - Levels softened at the open (3/5% -> 2.5/4.5%), nudged at the top (30% -> 32%); final 55% squeeze unchanged. Same levels across presets; presets only change pace. Values are data-informed PLAYTEST STARTING POINTS — tune against live curves. Tests updated to the new schedule. --- src/core/game/DoomsdayClock.ts | 146 +++++++++++++++------------ tests/DoomsdayClockExecution.test.ts | 72 ++++++------- 2 files changed, 120 insertions(+), 98 deletions(-) diff --git a/src/core/game/DoomsdayClock.ts b/src/core/game/DoomsdayClock.ts index 1d3575c57f..c1a9fe4061 100644 --- a/src/core/game/DoomsdayClock.ts +++ b/src/core/game/DoomsdayClock.ts @@ -24,51 +24,57 @@ export const DOOMSDAY_CLOCK_SPEEDS: DoomsdayClockSpeed[] = [ interface WaveSchedule { /** Flat 0% for this long at the very start (the one grace period). */ graceSeconds: number; - /** Each wave grows its share up linearly over this long. */ - rampSeconds: number; - /** Flat hold after each ramp before the next one starts. */ - pauseSeconds: number; + /** Per-wave: wave i grows its share up linearly over rampSeconds[i]. */ + rampSeconds: number[]; + /** Per-wave: flat hold after wave i's ramp before the next one starts. */ + pauseSeconds: number[]; /** Share (basis points, 100 = 1%) reached at the end of each ramp, ascending. */ levels: number[]; } -// Grace once, then a repeating cycle of [ramp up over rampSeconds] + [hold for -// pauseSeconds]. The share rises linearly during each ramp and is flat during -// the grace and every pause. Easy to tune: change grace, ramp, pause, or levels. -// Same levels everywhere (the ofstats FFA territory median, then a final 55% -// squeeze); the presets only change the pace. The median run is 3/5/10/20/30%; -// normal hits it dead on at 10/15/20/25/30 min. The 6th wave (55%) only one side -// can hold, so, together with the crown exemption, it forces out everyone but -// the leader for a single winner. slow is ~20% slower, fast ~30% faster, very -// fast 50% faster. -const LEVELS = [300, 500, 1000, 2000, 3000, 5500]; // 3, 5, 10, 20, 30, 55% +// Grace once, then per wave a [ramp up over rampSeconds[i]] + [hold for +// pauseSeconds[i]]. The share rises linearly during each ramp and is flat during +// the grace and every pause. Ramps/pauses are PER WAVE (not uniform) so the curve +// can be shaped, not just paced. +// +// Balance rationale (OFM 2026 Summer data): with a uniform cadence the cull was +// heavily front-loaded (~75% of eliminations in the first half — 85% of deaths are +// doom-driven) while the late game stalled (only ~7% of deaths in the last third) +// until the final squeeze. So the early waves are LOWER and come LATER (breathing +// room for smaller/newer players), and the late waves are STEEPER with no pauses +// (a continuous endgame squeeze that breaks the stalemate and ends games sooner). +// Levels are shared across presets (near the ofstats FFA territory median, then a +// final 55% squeeze only one side can hold → single winner with the crown +// exemption); presets only change the pace. NOTE: values are data-informed +// PLAYTEST STARTING POINTS — tune against live dev-lobby curves. +const LEVELS = [250, 450, 1000, 2000, 3200, 5500]; // 2.5, 4.5, 10, 20, 32, 55% const SCHEDULES: Record = { - // grace 5:30, 4:30 ramps + 30s pauses -> 3/5/10/20/30/55% at 10/15/20/25/30/35 min. + // grace 6:40; reaches 2.5/4.5/10/20/32/55% at ~11:40/17:00/21:10/23:55/25:55/27:35. normal: { - graceSeconds: 330, - rampSeconds: 270, - pauseSeconds: 30, + graceSeconds: 400, + rampSeconds: [300, 270, 220, 150, 120, 100], + pauseSeconds: [50, 50, 30, 15, 0, 0], levels: LEVELS, }, - // grace 6:30, 5:30 ramps -> reaches at 12/18/24/30/36/42 min. + // grace 8:00; reaches at ~14:00/20:20/25:20/28:40/31:10/33:10. slow: { - graceSeconds: 390, - rampSeconds: 330, - pauseSeconds: 30, + graceSeconds: 480, + rampSeconds: [360, 320, 260, 180, 150, 120], + pauseSeconds: [60, 60, 40, 20, 0, 0], levels: LEVELS, }, - // grace 4:30, 2:50 ramps -> reaches at 7:20/10:40/14/17:20/20:40/24 min. + // grace 5:00; reaches at ~8:40/12:40/15:40/17:40/19:10/20:20. fast: { - graceSeconds: 270, - rampSeconds: 170, - pauseSeconds: 30, + graceSeconds: 300, + rampSeconds: [220, 200, 160, 110, 90, 70], + pauseSeconds: [40, 40, 20, 10, 0, 0], levels: LEVELS, }, - // grace 3:00, 2:00 ramps -> reaches at 5/7:30/10/12:30/15/17:30 min. + // grace 3:30; reaches at ~6:00/8:50/10:55/12:45/13:53/14:43. veryfast: { - graceSeconds: 180, - rampSeconds: 120, - pauseSeconds: 30, + graceSeconds: 210, + rampSeconds: [150, 140, 110, 80, 60, 50], + pauseSeconds: [30, 30, 15, 8, 0, 0], levels: LEVELS, }, }; @@ -88,15 +94,18 @@ function requiredBasisPoints( ): number { const s = schedule(speed); if (elapsed <= s.graceSeconds) return 0; - const cycle = s.rampSeconds + s.pauseSeconds; - const t = elapsed - s.graceSeconds; - const i = Math.floor(t / cycle); - if (i >= s.levels.length) return s.levels[s.levels.length - 1]; - const into = t - i * cycle; - const prev = i === 0 ? 0 : s.levels[i - 1]; - const target = s.levels[i]; - if (into >= s.rampSeconds) return target; // in the pause: hold - return prev + Math.floor(((target - prev) * into) / s.rampSeconds); + let t = elapsed - s.graceSeconds; + let prev = 0; + for (let i = 0; i < s.levels.length; i++) { + const ramp = s.rampSeconds[i]; + const target = s.levels[i]; + if (t < ramp) return prev + Math.floor(((target - prev) * t) / ramp); // ramping + t -= ramp; + if (t < s.pauseSeconds[i]) return target; // in the pause: hold + t -= s.pauseSeconds[i]; + prev = target; + } + return s.levels[s.levels.length - 1]; } /** @@ -153,7 +162,6 @@ export function doomsdayClockWaveState( ): DoomsdayClockWaveState { const s = schedule(speed); const currentPercent = requiredBasisPoints(speed, elapsed) / 100; - const cycle = s.rampSeconds + s.pauseSeconds; const n = s.levels.length; const last = s.levels[n - 1] / 100; @@ -169,31 +177,45 @@ export function doomsdayClockWaveState( }; } - const t = elapsed - s.graceSeconds; - const i = Math.floor(t / cycle); - if (i >= n) { - return { - currentPercent, - targetPercent: last, - growing: false, - secondsToNextGrowth: 0, - waveFlash: false, - done: true, - }; + // Walk the per-wave ramp/pause segments to locate the current wave. + let t = elapsed - s.graceSeconds; + for (let i = 0; i < n; i++) { + const ramp = s.rampSeconds[i]; + const pause = s.pauseSeconds[i]; + const isLast = i === n - 1; + if (t < ramp) { + // Ramping up toward level i. + return { + currentPercent, + targetPercent: s.levels[i] / 100, + growing: true, + secondsToNextGrowth: 0, + waveFlash: t <= 5, // just started ramping + done: false, + }; + } + t -= ramp; + if (t < pause) { + // Holding after level i; next ramp begins in (pause - t)s. + return { + currentPercent, + targetPercent: (isLast ? s.levels[i] : s.levels[i + 1]) / 100, + growing: false, + secondsToNextGrowth: isLast ? 0 : pause - t, + waveFlash: !isLast && pause - t <= 5, // next ramp imminent + done: isLast, + }; + } + t -= pause; } - - const into = t - i * cycle; - const growing = into < s.rampSeconds; - const isLast = i === n - 1; - const nextRampStart = s.graceSeconds + (i + 1) * cycle; + // Past the final level. return { currentPercent, - targetPercent: (growing || isLast ? s.levels[i] : s.levels[i + 1]) / 100, - growing, - secondsToNextGrowth: growing || isLast ? 0 : nextRampStart - elapsed, - // 5s into a ramp (just started) or 5s before the next ramp begins. - waveFlash: into <= 5 || (!isLast && nextRampStart - elapsed <= 5), - done: isLast && !growing, + targetPercent: last, + growing: false, + secondsToNextGrowth: 0, + waveFlash: false, + done: true, }; } diff --git a/tests/DoomsdayClockExecution.test.ts b/tests/DoomsdayClockExecution.test.ts index b4900ff498..3946c48b19 100644 --- a/tests/DoomsdayClockExecution.test.ts +++ b/tests/DoomsdayClockExecution.test.ts @@ -24,11 +24,11 @@ import { playerInfo, setup } from "./util/Setup"; // real simulation is the final test. // // The exec reads the real "veryfast" waves. WAVE_TICK sits in the 20% hold -// window (elapsed 750-780), so the bar is a stable 20% of the map (land 1000 -> +// window (elapsed 765-773), so the bar is a stable 20% of the map (land 1000 -> // bar 200) while the drain/flag logic is exercised. // --------------------------------------------------------------------------- -const WAVE_TICK = 7600; // elapsed 760s -> veryfast 20% hold (bar 200 @ land 1000) +const WAVE_TICK = 7660; // elapsed 766s -> veryfast 20% hold (bar 200 @ land 1000) type SDConfig = ReturnType["doomsdayClockConfig"]>; @@ -477,23 +477,23 @@ describe("doomsdayClockRequiredTiles (ramping waves)", () => { const land = 10000; it("is 0 through the grace, ramps linearly, then holds during the pause", () => { - // normal: grace 330s, then a 270s ramp 0->3%, then a 30s hold, ... - expect(doomsdayClockRequiredTiles("normal", land, 200)).toBe(0); // in the grace - expect(doomsdayClockRequiredTiles("normal", land, 330)).toBe(0); // grace ends - expect(doomsdayClockRequiredTiles("normal", land, 465)).toBe(150); // halfway up -> 1.5% - expect(doomsdayClockRequiredTiles("normal", land, 600)).toBe(300); // ramp done -> 3% - expect(doomsdayClockRequiredTiles("normal", land, 615)).toBe(300); // pause holds 3% - expect(doomsdayClockRequiredTiles("normal", land, 630)).toBe(300); // next ramp starts at 3% + // normal: grace 400s, then a 300s ramp 0->2.5%, then a 50s hold, ... + expect(doomsdayClockRequiredTiles("normal", land, 250)).toBe(0); // in the grace + expect(doomsdayClockRequiredTiles("normal", land, 400)).toBe(0); // grace ends + expect(doomsdayClockRequiredTiles("normal", land, 550)).toBe(125); // halfway up -> 1.25% + expect(doomsdayClockRequiredTiles("normal", land, 700)).toBe(250); // ramp done -> 2.5% + expect(doomsdayClockRequiredTiles("normal", land, 720)).toBe(250); // pause holds 2.5% + expect(doomsdayClockRequiredTiles("normal", land, 750)).toBe(250); // next ramp starts at 2.5% expect(doomsdayClockRequiredTiles("normal", land, 9999)).toBe(5500); // final 55% }); - it("passes 30% then reaches the final 55% squeeze per preset", () => { - // 30% waypoint, then the 6th wave to 55% one cycle later. - expect(doomsdayClockRequiredTiles("normal", land, 1800)).toBe(3000); // 30% @ 30:00 - expect(doomsdayClockRequiredTiles("normal", land, 2100)).toBe(5500); // 55% @ 35:00 - expect(doomsdayClockRequiredTiles("fast", land, 1440)).toBe(5500); // 55% @ 24:00 - expect(doomsdayClockRequiredTiles("veryfast", land, 1050)).toBe(5500); // 55% @ 17:30 - expect(doomsdayClockRequiredTiles("slow", land, 2520)).toBe(5500); // 55% @ 42:00 + it("reaches the 32% wave then the final 55% squeeze per preset", () => { + // 32% waypoint (5th wave), then the 6th wave to 55%. + expect(doomsdayClockRequiredTiles("normal", land, 1605)).toBe(3200); // 32% wave + expect(doomsdayClockRequiredTiles("normal", land, 1710)).toBe(5500); // 55% @ ~28:25 + expect(doomsdayClockRequiredTiles("fast", land, 1270)).toBe(5500); // 55% @ ~21:00 + expect(doomsdayClockRequiredTiles("veryfast", land, 890)).toBe(5500); // 55% @ ~14:43 + expect(doomsdayClockRequiredTiles("slow", land, 2060)).toBe(5500); // 55% @ ~34:10 }); it("never decreases, and is zero for no land", () => { @@ -511,51 +511,51 @@ describe("doomsdayClockSideRequiredTiles (headcount scaling)", () => { const land = 10000; it("scales the base share by side size and caps at the whole map", () => { - // veryfast at 900s is the final 30% wave -> base 3000 tiles. - expect(doomsdayClockRequiredTiles("veryfast", land, 900)).toBe(3000); - expect(doomsdayClockSideRequiredTiles("veryfast", land, 900, 1)).toBe(3000); // solo - expect(doomsdayClockSideRequiredTiles("veryfast", land, 900, 2)).toBe(6000); // 2x - expect(doomsdayClockSideRequiredTiles("veryfast", land, 900, 4)).toBe( + // veryfast at 768s sits in the 20% hold -> base 2000 tiles. + expect(doomsdayClockRequiredTiles("veryfast", land, 768)).toBe(2000); + expect(doomsdayClockSideRequiredTiles("veryfast", land, 768, 1)).toBe(2000); // solo + expect(doomsdayClockSideRequiredTiles("veryfast", land, 768, 2)).toBe(4000); // 2x + expect(doomsdayClockSideRequiredTiles("veryfast", land, 768, 6)).toBe( 10000, - ); // capped - expect(doomsdayClockSideRequiredTiles("veryfast", land, 900, 0)).toBe(3000); // min size 1 + ); // 12000 capped at the map + expect(doomsdayClockSideRequiredTiles("veryfast", land, 768, 0)).toBe(2000); // min size 1 }); }); describe("doomsdayClockWaveState", () => { it("reports the live share and target while ramping", () => { - const s = doomsdayClockWaveState("normal", 465); // mid the first ramp (0->3%) - expect(s.currentPercent).toBe(1.5); - expect(s.targetPercent).toBe(3); + const s = doomsdayClockWaveState("normal", 550); // mid the first ramp (0->2.5%) + expect(s.currentPercent).toBe(1.25); + expect(s.targetPercent).toBe(2.5); expect(s.growing).toBe(true); expect(s.secondsToNextGrowth).toBe(0); expect(s.done).toBe(false); }); it("counts down to the next ramp during a pause", () => { - const s = doomsdayClockWaveState("normal", 615); // in the first pause (600-630) + const s = doomsdayClockWaveState("normal", 720); // in the first pause (700-750) expect(s.growing).toBe(false); - expect(s.currentPercent).toBe(3); // held at the level just reached - expect(s.targetPercent).toBe(5); // next ramp climbs to 5% - expect(s.secondsToNextGrowth).toBe(15); // next ramp starts at 630 + expect(s.currentPercent).toBe(2.5); // held at the level just reached + expect(s.targetPercent).toBe(4.5); // next ramp climbs to 4.5% + expect(s.secondsToNextGrowth).toBe(30); // next ramp starts at 750 }); it("counts down through the grace", () => { const s = doomsdayClockWaveState("normal", 200); expect(s.currentPercent).toBe(0); - expect(s.targetPercent).toBe(3); - expect(s.secondsToNextGrowth).toBe(130); // first ramp at 330 + expect(s.targetPercent).toBe(2.5); + expect(s.secondsToNextGrowth).toBe(200); // first ramp at 400 }); it("flags the 10s window (5s each side) around a ramp starting", () => { - // veryfast first ramp starts at 180s. - expect(doomsdayClockWaveState("veryfast", 176).waveFlash).toBe(true); // 4s before - expect(doomsdayClockWaveState("veryfast", 184).waveFlash).toBe(true); // 4s after + // veryfast first ramp starts at 210s. + expect(doomsdayClockWaveState("veryfast", 206).waveFlash).toBe(true); // 4s before + expect(doomsdayClockWaveState("veryfast", 214).waveFlash).toBe(true); // 4s after expect(doomsdayClockWaveState("veryfast", 250).waveFlash).toBe(false); // mid-ramp }); it("marks done after the last ramp", () => { - const s = doomsdayClockWaveState("veryfast", 1100); // past the final ramp (@1050) = 55% + const s = doomsdayClockWaveState("veryfast", 1100); // past the final ramp (@883) = 55% expect(s.done).toBe(true); expect(s.currentPercent).toBe(55); expect(s.secondsToNextGrowth).toBe(0);