diff --git a/src/stats/progression.test.ts b/src/stats/progression.test.ts index ee4c946a..896d8c6d 100644 --- a/src/stats/progression.test.ts +++ b/src/stats/progression.test.ts @@ -8,6 +8,7 @@ import { import { type PlayerDataPIT, type StatsPIT } from "#queries/playerdata.ts"; import { type History } from "#queries/history.ts"; import { ALL_GAMEMODE_KEYS, type GamemodeKey, type StatKey } from "./keys.ts"; +import { bedwarsLevelFromExp } from "./stars.ts"; const TEST_UUID = "0123e456-7890-1234-5678-90abcdef1234"; @@ -1137,3 +1138,712 @@ await test("computeStatProgression - stars/experience stat", async (t) => { }); } }); + +await test("computeStatProgression - index stat", async (t) => { + await t.test("success cases", async (t) => { + // Note: Index = fkdr^2 * stars + // We use average exp per star for progression: PRESTIGE_EXP / 100 = 487000 / 100 = 4870 + // We use actual star calculation for index calculation + // const EASY_LEVEL_COSTS = { 1: 500, 2: 1000, 3: 2000, 4: 3500 }; Rest: 5000 + + // Given: + // s_0 = initial stars + // s = stars gained per day + // k_0 = initial final kills + // k = final kills gained per day + // d_0 = initial final deaths + // d = final deaths gained per day + // M = target index milestone + // t = days until milestone + // + // We have: + // index(t) = (s_0 + s*t) * (k_0+k*t)^2/(d_0+d*t)^2 = M + // -> (s_0 + s*t) * (k_0^2 + 2k_0*k*t + k^2*t^2) / (d_0^2 + 2d_0*d*t + d^2*t^2) = M + // -> (s_0 + s*t) * (k_0^2 + 2k_0*k*t + k^2*t^2) = M * (d_0^2 + 2d_0*d*t + d^2*t^2) + // -> s_0*k_0^2 + 2s_0*k_0*k*t + s_0*k^2*t^2 + s*k_0^2*t + 2s*k_0*k*t^2 + s*k^2*t^3 = M*d_0^2 + 2M*d_0*d*t + M*d^2*t^2 + // -> s*k^2*t^3 + s_0*k^2*t^2 + 2s*k_0*k*t^2 - M*d^2*t^2 + 2s_0*k_0*k*t + s*k_0^2*t - 2M*d_0*d*t - M*d_0^2 + s_0*k_0^2 = 0 + // -> (s*k^2)*t^3 + (s_0*k^2 + 2s*k_0*k - M*d^2)*t^2 + (2s_0*k_0*k + s*k_0^2 - 2M*d_0*d)*t + (s_0*k_0^2 - M*d_0^2) = 0 + // -> a = s*k^2 + // b = s_0*k^2 + 2s*k_0*k - M*d^2 + // c = 2s_0*k_0*k + s*k_0^2 - 2M*d_0*d + // d = s_0*k_0^2 - M*d_0^2 + // + // Interesting edge cases: + // a = 0 + // a = 0 ^ b = 0 + // a = 0 ^ b = 0 ^ c = 0 + // + // Discriminant: 18abcd - 4b^3d + b^2c^2 - 4ac^3 -27a^2d^2 + // Positive + // Zero + // Negative + + const computeCoefficients = (c: Case) => { + const s0 = bedwarsLevelFromExp(c.trackingStats.end.experience); + const s = + (c.trackingStats.end.experience - + c.trackingStats.start.experience) / + 4870 / + c.trackingStats.durationDays; + const k0 = c.trackingStats.end.finalKills; + const k = + (c.trackingStats.end.finalKills - + c.trackingStats.start.finalKills) / + c.trackingStats.durationDays; + const d0 = c.trackingStats.end.finalDeaths; + const d = + (c.trackingStats.end.finalDeaths - + c.trackingStats.start.finalDeaths) / + c.trackingStats.durationDays; + const M = c.expected.milestone; + return { + a: s * k * k, + b: s0 * k * k + 2 * s * k0 * k - M * d * d, + c: 2 * s0 * k0 * k + s * k0 * k0 - 2 * M * d0 * d, + d: s0 * k0 * k0 - M * d0 * d0, + } as const; + }; + + const computeDiscriminant = ({ + a, + b, + c, + d, + }: ReturnType) => { + return ( + 18 * a * b * c * d - + 4 * b * b * b * d + + b * b * c * c - + 4 * a * c * c * c - + 27 * a * a * d * d + ); + }; + + type Sign = "positive" | "zero" | "negative"; + + interface Case { + name: string; + trackingStats: { + durationDays: number; + start: { + experience: number; + finalKills: number; + finalDeaths: number; + }; + end: { + experience: number; + finalKills: number; + finalDeaths: number; + }; + }; + expected: { + index: number; + milestone: number; + daysUntilMilestone: number; + progressPerDay: number; + cubic?: { + discriminant: Sign; + a: Sign; + b: Sign; + c: Sign; + d: Sign; + }; + }; + } + + const cases: Case[] = [ + { + name: "no progress", + trackingStats: { + durationDays: 10, + start: { + // No progress + experience: 500, + finalKills: 10, + finalDeaths: 5, + }, + end: { + experience: 500, + finalKills: 10, // 2 fkdr + finalDeaths: 5, + }, + }, + expected: { + index: 16, // 4 stars * (2 fkdr)^2 + milestone: 20, + daysUntilMilestone: Infinity, + progressPerDay: 0, + cubic: { + discriminant: "zero", + a: "zero", + b: "zero", + c: "zero", + d: "negative", + }, + }, + }, + { + name: "no finals", + trackingStats: { + durationDays: 10, + start: { + experience: 500, + finalKills: 0, + finalDeaths: 5, + }, + end: { + experience: 7000, // 4 stars + finalKills: 0, // 0 fkdr + finalDeaths: 10, + }, + }, + expected: { + index: 0, // 4 stars * (0 fkdr)^2 + milestone: 1, + daysUntilMilestone: Infinity, // constant fkdr at 0 + progressPerDay: 0, + cubic: { + discriminant: "zero", + a: "zero", + b: "negative", + c: "negative", + d: "negative", + }, + }, + }, + { + name: "no stars", + trackingStats: { + durationDays: 10, + start: { + experience: 0, // NOTE: 0 stars is not possible to attain, as all players start with 500 exp + finalKills: 0, + finalDeaths: 1, + }, + end: { + experience: 0, // 0 stars + finalKills: 100, + finalDeaths: 1, + }, + }, + expected: { + index: 0, // 4 stars * (0 fkdr)^2 + milestone: 1, + daysUntilMilestone: Infinity, // constant fkdr at 0 + progressPerDay: 0, + cubic: { + discriminant: "zero", + a: "zero", + b: "zero", + c: "zero", + d: "negative", + }, + }, + }, + { + name: "increasing star, stable fkdr", + trackingStats: { + durationDays: 10, + start: { + experience: 2130, // 4870 (1 avg star) difference + finalKills: 10, // 2 fkdr -> 2 session fkdr + finalDeaths: 5, + }, + end: { + experience: 7000, // 4 stars + finalKills: 20, // 2 fkdr + finalDeaths: 10, + }, + }, + expected: { + index: 16, // 4 stars * (2 fkdr)^2 + milestone: 20, + daysUntilMilestone: 10, // stable fkdr -> need to get to 5 stars (gain 1 star) -> 10 days (same as tracking interval) + progressPerDay: 0.4, + cubic: { + discriminant: "zero", + a: "positive", + b: "positive", + c: "zero", + d: "negative", + }, + }, + }, + { + name: "increasing fkdr, zero final deaths, stable stars", + // NOTE: In this case the equation changes as we calculate fkdr as just final kills + // -> We skip the assertions about the shape of the cubic equation + trackingStats: { + durationDays: 10, + start: { + experience: 500, // 0 star progress - not really possible, but interesting to test + finalKills: 10, // 1 final per day + finalDeaths: 0, + }, + end: { + experience: 500, + finalKills: 20, // 20 fkdr, trending up by 1 fkdr/day + finalDeaths: 0, + }, + }, + expected: { + index: 400, // 1 star * (20 fkdr)^2 + milestone: 500, + /* 500 index + * -> sqrt(500) fkdr + * -> 20 + t = sqrt(500) + * -> t = sqrt(500) - 20 + */ + daysUntilMilestone: Math.sqrt(500) - 20, + /* (500-400) / daysUntilMilestone + * = (100) / (sqrt(500) - 20) + * = (100 * (sqrt(500) + 20)) / (500 - 400) + * = sqrt(500) + 20 + */ + progressPerDay: Math.sqrt(500) + 20, + }, + }, + { + name: "increasing fkdr, stable final deaths, stable stars", + trackingStats: { + durationDays: 10, + start: { + experience: 500, // 0 star progress - not really possible, but interesting to test + finalKills: 10, // 1 final per day + finalDeaths: 2, // Non-zero stable final deaths + }, + end: { + experience: 500, + finalKills: 20, // 10 fkdr, trending up by 0.5 fkdr/day + finalDeaths: 2, + }, + }, + expected: { + index: 100, // 1 star * (10 fkdr)^2 + milestone: 200, + /* 200 index + * -> sqrt(200) fkdr + * -> (20 + t)/2 = sqrt(200) + * -> t = 2 * sqrt(200) - 20 = 20*sqrt(2) - 20 + */ + daysUntilMilestone: 20 * Math.SQRT2 - 20, + /* (200-100) / daysUntilMilestone + * = 100 / (20*sqrt(2) - 20) + * = 100 * (20*sqrt(2) + 20) / (800 - 400) + * = 5*sqrt(2)+5 + */ + progressPerDay: 5 * Math.SQRT2 + 5, + cubic: { + discriminant: "positive", + a: "zero", + b: "positive", + c: "positive", + d: "negative", + }, + }, + }, + { + name: "increasing fkdr, stable stars", + trackingStats: { + durationDays: 10, + start: { + experience: 500, // 0 star progress - not really possible, but interesting to test + finalKills: 0, // 2 finals per day (20 session fkdr) + finalDeaths: 1, // 0.1 final death per day + }, + end: { + experience: 500, + finalKills: 20, // 10 fkdr + finalDeaths: 2, + }, + }, + expected: { + index: 100, // 1 star * (10 fkdr)^2 + milestone: 200, + /* 200 index + * -> sqrt(200) fkdr + * -> (20 + 2t) / (2+0.1t) + * = sqrt(200) -> 20 + 2t + * = 2*sqrt(200)+0.1*sqrt(200)*t + * -> t = (2 * sqrt(200) - 20) / (2 - 0.1*sqrt(200)) + * = (20*sqrt(2) - 20) / (2 - sqrt(2)) + * = (20*sqrt(2)-20)*(2 + sqrt(2)) / (4 - 2) + * = (40*sqrt(2) + 40 - 40 - 20*sqrt(2)) / 2 + * = 10*sqrt(2) + */ + daysUntilMilestone: 10 * Math.SQRT2, + /* (200-100) / daysUntilMilestone + * = 100 / (10*sqrt(2)) + * = 5*sqrt(2) + */ + progressPerDay: 5 * Math.SQRT2, + cubic: { + discriminant: "positive", + a: "zero", + b: "positive", + c: "zero", + d: "negative", + }, + }, + }, + { + name: "increasing plateauing fkdr, stable stars", + trackingStats: { + durationDays: 10, + start: { + experience: 500, // 0 star progress - not really possible, but interesting to test + finalKills: 16, // 2 finals per day (20 session fkdr) + finalDeaths: 1, // 0.1 final death per day + }, + end: { + experience: 500, + finalKills: 36, // 18 fkdr + finalDeaths: 2, + }, + }, + expected: { + index: 324, // 1 star * (18 fkdr)^2 + milestone: 400, + /* 400 index + * -> sqrt(400) = 20 fkdr + * This is our session fkdr, so we will asymptotically approach it but never reach it + * -> Milestone is unreachable + */ + daysUntilMilestone: Infinity, + // (400-324) / Infinity = 0 + progressPerDay: 0, + cubic: { + discriminant: "zero", + a: "zero", + b: "zero", + c: "negative", + d: "negative", + }, + }, + }, + { + name: "decreasing fkdr, stable stars", + trackingStats: { + durationDays: 10, + start: { + experience: 500, // 0 star progress - not really possible, but interesting to test + finalKills: 15, // 0.5 final per day (5 session fkdr) + finalDeaths: 1, // 0.1 final death per day + }, + end: { + experience: 500, + finalKills: 20, // 10 fkdr + finalDeaths: 2, + }, + }, + expected: { + index: 100, // 1 star * (10 fkdr)^2 + milestone: 90, + /* 90 index + * -> sqrt(90) fkdr + * -> (20+0.5*t)/(2+0.1*t) = sqrt(90) + * -> (20+0.5*t) = 2*sqrt(90) + sqrt(90)*0.1*t + * -> t*(0.5 - 0.1*sqrt(90)) = 2*sqrt(90) - 20 + * -> t = (2*sqrt(90)-20) / (0.5 - 0.1*sqrt(90)) + * = (60*sqrt(10)-200) / (5 - 3*sqrt(10)) + * = (60*sqrt(10)-200) * (5 + 3*sqrt(10)) / (25 - 90) + * = 300*sqrt(10) + 1800 - 1000 - 600*sqrt(10)) / -65 + * = (300*sqrt(10) - 800) / 65 + * = (60*sqrt(10) - 160) / 13 + */ + daysUntilMilestone: (60 * Math.sqrt(10) - 160) / 13, + /* (90-100) / daysUntilMilestone + * = -10 / ((60*sqrt(10) - 160) / 13) + * = -130 * (60*sqrt(10) + 160) / (36000 - 25600) + * = -(20800 + 7800*sqrt(10)) / 10400 + * = -(8 + 3*sqrt(10)) / 4 + */ + progressPerDay: -(8 + 3 * Math.sqrt(10)) / 4, + cubic: { + discriminant: "positive", + a: "zero", + b: "negative", + c: "negative", + d: "positive", + }, + }, + }, + { + name: "decreasing plateauing fkdr, stable stars", + trackingStats: { + durationDays: 10, + start: { + experience: 500, // 0 star progress - not really possible, but interesting to test + finalKills: 16, // 0.5 final per day (5 session fkdr) + finalDeaths: 3, // 0.1 final death per day + }, + end: { + experience: 500, + finalKills: 21, // 5.25 fkdr + finalDeaths: 4, + }, + }, + expected: { + index: 27.5625, // 1 star * (5.25 fkdr)^2 + milestone: 20, + /* 20 index + * -> sqrt(20) fkdr + * This is below our session fkdr. We will asymptotically 25, but never reach it or anything below. + * -> Milestone is unreachable + */ + daysUntilMilestone: Infinity, + // (20-27.5625) / Infinity = 0 + progressPerDay: 0, + cubic: { + discriminant: "positive", + a: "zero", + b: "positive", + c: "positive", + d: "positive", + }, + }, + }, + { + name: "index decreasing past next milestone before increasing", + trackingStats: { + durationDays: 10, + start: { + experience: 4565, // 0.5 stars per day + finalKills: 60, // 2 finals per day + finalDeaths: 3, // 0.5 final death per day + }, + end: { + experience: 7000, // 4 stars + finalKills: 80, // 10 fkdr + finalDeaths: 8, + }, + }, + expected: { + // index(t) = (4 + 0.1*t) * (80+2*t)^2/(8+0.5*t)^2 + index: 400, // 4 star * (10 fkdr)^2 + milestone: 300, + // Shamelessly solved by WolframAlpha + daysUntilMilestone: 9.21165, + // (300 - 400) / daysUntilMilestone + progressPerDay: -100 / 9.21165, + cubic: { + discriminant: "positive", + a: "positive", + b: "negative", + c: "negative", + d: "positive", + }, + }, + }, + { + name: "index decreasing not reaching next milestone before increasing", + trackingStats: { + durationDays: 10, + start: { + experience: 1065, // 0.5 stars per day + finalKills: 60, // 2 finals per day + finalDeaths: 3, // 0.5 final death per day + }, + end: { + experience: 3500, // 3 stars + finalKills: 80, // 10 fkdr + finalDeaths: 8, + }, + }, + expected: { + // index(t) = (3 + 0.1*t) * (80+2*t)^2/(8+0.5*t)^2 + index: 300, // 3 star * (10 fkdr)^2 + milestone: 200, + // Reaches minimum just above 200 at around t=24 days + daysUntilMilestone: Infinity, + // (200 - 300) / daysUntilMilestone + progressPerDay: 0, + cubic: { + discriminant: "positive", + a: "positive", + b: "negative", + c: "negative", + d: "positive", + }, + }, + }, + { + name: "index increasing past next milestone", + trackingStats: { + durationDays: 10, + start: { + experience: 690 * 4870, // 1 stars per day + finalKills: 17_800, // 20 finals per day + finalDeaths: 1_790, // 1 final death per day + }, + end: { + experience: 700 * 4870, // 700 stars + finalKills: 18_000, // 10 fkdr + finalDeaths: 1_800, + }, + }, + expected: { + // index(t) = (700 + 1*t) * (18000+20*t)^2/(1800+1*t)^2 + index: 70_000, // 700 star * (10 fkdr)^2 + milestone: 80_000, + // Shamelessly solved by WolframAlpha + daysUntilMilestone: 54.768, + // (80_000 - 70_000) / daysUntilMilestone + progressPerDay: 10_000 / 54.768, + cubic: { + discriminant: "negative", + a: "positive", + b: "positive", + c: "positive", + d: "negative", + }, + }, + }, + ]; + + for (const c of cases) { + await t.test(c.name, () => { + if (c.expected.cubic) { + const checkCoefficientSign = ( + value: number, + expected: Sign, + name: string, + ) => { + switch (expected) { + case "positive": + assert.ok( + value > 0, + `${name} should be positive, got ${value.toString()}`, + ); + break; + case "zero": + assert.ok( + Math.abs(value) < 1e-6, + `${name} should be zero, got ${value.toString()}`, + ); + break; + case "negative": + assert.ok( + value < 0, + `${name} should be negative, got ${value.toString()}`, + ); + break; + default: + expected satisfies never; + } + }; + + const coefficients = computeCoefficients(c); + const discriminants = computeDiscriminant(coefficients); + + checkCoefficientSign( + coefficients.a, + c.expected.cubic.a, + "Coefficient a", + ); + checkCoefficientSign( + coefficients.b, + c.expected.cubic.b, + "Coefficient b", + ); + checkCoefficientSign( + coefficients.c, + c.expected.cubic.c, + "Coefficient c", + ); + checkCoefficientSign( + coefficients.d, + c.expected.cubic.d, + "Coefficient d", + ); + checkCoefficientSign( + discriminants, + c.expected.cubic.discriminant, + "Discriminant", + ); + } + + const startDate = new Date("2024-01-01T00:00:00Z"); + const endDate = new Date( + startDate.getTime() + + c.trackingStats.durationDays * 24 * 60 * 60 * 1000, + ); + + const history: History = [ + new PlayerDataBuilder(TEST_UUID, startDate) + .withExperience(c.trackingStats.start.experience) + .withGamemodeStats( + "overall", + new StatsBuilder() + .withStat( + "finalKills", + c.trackingStats.start.finalKills, + ) + .withStat( + "finalDeaths", + c.trackingStats.start.finalDeaths, + ) + .build(), + ) + .build(), + new PlayerDataBuilder(TEST_UUID, endDate) + .withExperience(c.trackingStats.end.experience) + .withGamemodeStats( + "overall", + new StatsBuilder() + .withStat( + "finalKills", + c.trackingStats.end.finalKills, + ) + .withStat( + "finalDeaths", + c.trackingStats.end.finalDeaths, + ) + .build(), + ) + .build(), + ]; + + const result = computeStatProgression( + history, + endDate, + "index", + "overall", + ); + + if (result.error) { + assert.fail( + `Expected success but got error: ${result.reason}`, + ); + } + + // Destructure result for float comparisons + const { daysUntilMilestone, progressPerDay, ...rest } = result; + + // Deep strict equal on exact values + assert.deepStrictEqual(rest, { + stat: "index", + endValue: c.expected.index, + nextMilestoneValue: c.expected.milestone, + trendingUpward: c.expected.milestone >= c.expected.index, + trackingDataTimeInterval: { + start: startDate, + end: endDate, + }, + }); + + // "Close enough" checks for floats + assert.ok( + Math.abs( + daysUntilMilestone - c.expected.daysUntilMilestone, + ) < 1e-3, + `daysUntilMilestone ${daysUntilMilestone.toString()} should be close to ${c.expected.daysUntilMilestone.toString()}`, + ); + assert.ok( + Math.abs(progressPerDay - c.expected.progressPerDay) < 1e-3, + `progressPerDay ${progressPerDay.toString()} should be close to ${c.expected.progressPerDay.toString()}`, + ); + }); + } + }); +});