Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 84 additions & 62 deletions src/core/game/DoomsdayClock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DoomsdayClockSpeed, WaveSchedule> = {
// 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,
},
};
Expand All @@ -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];
}

/**
Expand Down Expand Up @@ -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;

Expand All @@ -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,
};
}

Expand Down
72 changes: 36 additions & 36 deletions tests/DoomsdayClockExecution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<Game["config"]>["doomsdayClockConfig"]>;

Expand Down Expand Up @@ -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", () => {
Expand All @@ -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);
Expand Down
Loading