diff --git a/src/routes/session/$uuid.tsx b/src/routes/session/$uuid.tsx index d9ed9bca..93210bba 100644 --- a/src/routes/session/$uuid.tsx +++ b/src/routes/session/$uuid.tsx @@ -148,7 +148,9 @@ const renderDuration = (end: Date, start: Date) => { const BAD_STATS: readonly StatKey[] = ["deaths", "finalDeaths", "bedsLost", "losses"]; const isLinearStat = (stat: StatKey) => { - return !["fkdr", "kdr", "bblr", "wlr", "index", "winrate"].includes(stat); + return !["fkdr", "kdr", "bblr", "wlr", "index", "winrate", "clutchRate"].includes( + stat, + ); }; const getRelatedStats = (stat: StatKey): StatKey[] => { @@ -168,6 +170,9 @@ const getRelatedStats = (stat: StatKey): StatKey[] => { case "winrate": { return ["wins", "gamesPlayed"]; } + case "clutchRate": { + return ["bedsLost", "losses"]; + } case "index": { return ["finalKills", "finalDeaths", "stars"]; } @@ -969,6 +974,16 @@ const ProgressionCaption: React.FC = ({ progression }) ); } + case "clutchRate": { + // progressPerDay and sessionQuotient are fractions -> render as %. + // dividendPerDay (clutch wins/day) and divisorPerDay (beds lost/day) + // are plain counts, not clutch rates -> keep the plain 2dp format. + return ( + + {`${formatStatValue("clutchRate", progression.progressPerDay)}/day (${formatStatValue("clutchRate", progression.sessionQuotient)} long-time ${getShortStatLabel("clutchRate")}, ${progression.dividendPerDay.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} clutch ${getShortStatLabel("wins")}/day, ${progression.divisorPerDay.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${getShortStatLabel("bedsLost")}/day)`} + + ); + } case "index": { return ( @@ -1258,6 +1273,7 @@ function RouteComponent() { "bblr", "wlr", "winrate", + "clutchRate", ]; return ( diff --git a/src/stats/format.ts b/src/stats/format.ts index 74853bca..0f9daa19 100644 --- a/src/stats/format.ts +++ b/src/stats/format.ts @@ -22,6 +22,7 @@ export const STAT_FORMAT_KIND: Record = { bblr: "decimal", wlr: "decimal", winrate: "percentage", + clutchRate: "percentage", index: "integer", }; diff --git a/src/stats/index.ts b/src/stats/index.ts index 7e73cbbc..35e0186f 100644 --- a/src/stats/index.ts +++ b/src/stats/index.ts @@ -64,6 +64,13 @@ export function getStat( // A win always increments gamesPlayed, so gamesPlayed === 0 ⇒ wins === 0. return gamesPlayed === 0 ? 0 : wins / gamesPlayed; } + case "clutchRate": { + const { bedsLost, losses } = selectedGamemode; + // Clutch rate = win rate in games where you lost your bed. Every loss + // loses your bed, so bed-loss games = bedsLost and clutch wins = + // bedsLost - losses. bedsLost === 0 ⇒ losses === 0 ⇒ no bed-loss games. + return bedsLost === 0 ? 0 : (bedsLost - losses) / bedsLost; + } case "index": { const fkdr = getStat(playerData, gamemode, "fkdr"); const stars = getStat(playerData, gamemode, "stars"); @@ -201,6 +208,26 @@ export function computeStat( // A win always increments gamesPlayed, so gamesPlayed === 0 ⇒ wins === 0. return gamesPlayed === 0 ? 0 : wins / gamesPlayed; } + case "clutchRate": { + const bedsLost = computeStat( + playerData, + gamemode, + "bedsLost", + variant, + history, + ); + const losses = computeStat( + playerData, + gamemode, + "losses", + variant, + history, + ); + // Clutch rate = win rate in games where you lost your bed. Every loss + // loses your bed, so bed-loss games = bedsLost and clutch wins = + // bedsLost - losses. bedsLost === 0 ⇒ losses === 0 ⇒ no bed-loss games. + return bedsLost === 0 ? 0 : (bedsLost - losses) / bedsLost; + } case "index": { const fkdr = computeStat(playerData, gamemode, "fkdr", variant, history); const stars = computeStat(playerData, gamemode, "stars", variant, history); diff --git a/src/stats/keys.ts b/src/stats/keys.ts index 82b70591..5e514b92 100644 --- a/src/stats/keys.ts +++ b/src/stats/keys.ts @@ -18,9 +18,7 @@ export const GAMEMODE_STAT_KEYS = [ "wlr", "winrate", "index", - /* "clutchRate", - */ ] as const; export type GamemodeStatKey = (typeof GAMEMODE_STAT_KEYS)[number]; diff --git a/src/stats/labels.ts b/src/stats/labels.ts index 3bbc7e35..1506fc46 100644 --- a/src/stats/labels.ts +++ b/src/stats/labels.ts @@ -53,6 +53,9 @@ export const getFullStatLabel = (stat: StatKey, capitalize = false): string => { case "winrate": { return capitalize ? "Win rate" : "win rate"; } + case "clutchRate": { + return capitalize ? "Clutch rate" : "clutch rate"; + } case "index": { return capitalize ? "Index (FKDR^2 * Stars)" : "index (FKDR^2 * stars)"; } @@ -112,6 +115,9 @@ export const getShortStatLabel = (stat: StatKey, capitalize = false): string => case "winrate": { return capitalize ? "WR" : "WR"; } + case "clutchRate": { + return capitalize ? "Clutch" : "clutch"; + } case "index": { return capitalize ? "Index" : "index"; } diff --git a/src/stats/progression.ts b/src/stats/progression.ts index 85158007..db44f04f 100644 --- a/src/stats/progression.ts +++ b/src/stats/progression.ts @@ -1,5 +1,6 @@ import type { TimeInterval } from "#intervals.ts"; import type { History } from "#queries/history.ts"; +import type { PlayerDataPIT } from "#queries/playerdata.ts"; import { getStat } from "./index.ts"; import type { GamemodeKey, StatKey } from "./keys.ts"; @@ -153,12 +154,13 @@ export const nextNaturalMilestone: MilestoneStrategy = ( case "wlr": { return trendingUpward ? Math.floor(value) + 1 : Math.ceil(value) - 1; } - case "winrate": { - // Winrate is a fraction in [0, 1]; step by 5% in the trend direction, - // clamped into [0, 1] so a near-perfect record can't project past 100% - // (or below 0%). At the clamp boundary this returns `value` itself, - // which the callers must treat as "already reached" (see the CONTRACT - // note above and computeQuotientProgression). + case "winrate": + case "clutchRate": { + // Winrate and clutch rate are fractions in [0, 1]; step by 5% in the + // trend direction, clamped into [0, 1] so a near-perfect record can't + // project past 100% (or below 0%). At the clamp boundary this returns + // `value` itself, which the callers must treat as "already reached" + // (see the CONTRACT note above and computeQuotientProgression). const step = 0.05; const next = trendingUpward ? (Math.floor(value / step + 1e-9) + 1) * step @@ -183,7 +185,7 @@ interface BaseStatProgression { } type QuotientProgression = BaseStatProgression & { - stat: "fkdr" | "kdr" | "bblr" | "wlr" | "winrate"; + stat: "fkdr" | "kdr" | "bblr" | "wlr" | "winrate" | "clutchRate"; sessionQuotient: number; dividendPerDay: number; divisorPerDay: number; @@ -199,7 +201,10 @@ type IndexProgression = BaseStatProgression & { export type StatProgression = | (BaseStatProgression & { - stat: Exclude; + stat: Exclude< + StatKey, + "fkdr" | "kdr" | "bblr" | "wlr" | "winrate" | "clutchRate" | "index" + >; }) | QuotientProgression | IndexProgression; @@ -208,12 +213,20 @@ const computeQuotientProgression = ( trackingHistory: History, trackingEnd: Date, stat: QuotientProgression["stat"], - dividendStat: Exclude, - divisorStat: Exclude, + // Dividend/divisor as value accessors rather than stat keys: clutch rate's + // dividend is synthetic (bedsLost - losses), not a single stored stat, so it + // can't be looked up by key like the other quotient stats. + getDividend: (playerData: PlayerDataPIT) => number, + getDivisor: (playerData: PlayerDataPIT) => number, gamemode: GamemodeKey, // Required (no default) so the public `computeStatProgression` boundary is // the single place the default strategy is applied. milestoneStrategy: MilestoneStrategy, + // The stat to project as a plain counter in the degenerate case where the + // divisor is 0 both at the end and across the session (the ratio collapses + // to "just the dividend"). A synthetic dividend (clutch rate) has no such + // counter and passes null, which reports no progress instead. + dividendFallbackStat: Exclude | null, ): QuotientProgression | { error: true; reason: string } => { const [start, end] = trackingHistory; const startDate = start.queriedAt; @@ -221,10 +234,10 @@ const computeQuotientProgression = ( const daysElapsed = (endDate.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000); - const startDividend = getStat(start, gamemode, dividendStat); - const startDivisor = getStat(start, gamemode, divisorStat); - const endDividend = getStat(end, gamemode, dividendStat); - const endDivisor = getStat(end, gamemode, divisorStat); + const startDividend = getDividend(start); + const startDivisor = getDivisor(start); + const endDividend = getDividend(end); + const endDivisor = getDivisor(end); const sessionDividend = endDividend - startDividend; const sessionDivisor = endDivisor - startDivisor; @@ -238,12 +251,19 @@ const computeQuotientProgression = ( const divisorPerDay = sessionDivisor / daysElapsed; if (endDivisor === 0 && sessionDivisor === 0) { + if (dividendFallbackStat === null) { + // Synthetic dividend (clutch rate): a zero divisor means no bed-loss + // games, so there is no clutch data and no counter to fall back to. + // Report no progress instead of dividing by zero (mirrors winrate, + // whose "wins" counter is likewise flat at 0 when no games are played). + return { error: true, reason: "No progress" }; + } // Have "infinite" ratio at the end -> ratio is computed as just dividend // oxlint-disable-next-line eslint/no-use-before-define const dividendProgression = computeStatProgression( trackingHistory, trackingEnd, - dividendStat, + dividendFallbackStat, gamemode, milestoneStrategy, ); @@ -438,10 +458,11 @@ export const computeStatProgression = ( trackingHistory, trackingEnd, stat, - "finalKills", - "finalDeaths", + (playerData) => getStat(playerData, gamemode, "finalKills"), + (playerData) => getStat(playerData, gamemode, "finalDeaths"), gamemode, milestoneStrategy, + "finalKills", ); } case "kdr": { @@ -449,10 +470,11 @@ export const computeStatProgression = ( trackingHistory, trackingEnd, stat, - "kills", - "deaths", + (playerData) => getStat(playerData, gamemode, "kills"), + (playerData) => getStat(playerData, gamemode, "deaths"), gamemode, milestoneStrategy, + "kills", ); } case "bblr": { @@ -460,10 +482,11 @@ export const computeStatProgression = ( trackingHistory, trackingEnd, stat, - "bedsBroken", - "bedsLost", + (playerData) => getStat(playerData, gamemode, "bedsBroken"), + (playerData) => getStat(playerData, gamemode, "bedsLost"), gamemode, milestoneStrategy, + "bedsBroken", ); } case "wlr": { @@ -471,10 +494,11 @@ export const computeStatProgression = ( trackingHistory, trackingEnd, stat, - "wins", - "losses", + (playerData) => getStat(playerData, gamemode, "wins"), + (playerData) => getStat(playerData, gamemode, "losses"), gamemode, milestoneStrategy, + "wins", ); } case "winrate": { @@ -482,10 +506,29 @@ export const computeStatProgression = ( trackingHistory, trackingEnd, stat, + (playerData) => getStat(playerData, gamemode, "wins"), + (playerData) => getStat(playerData, gamemode, "gamesPlayed"), + gamemode, + milestoneStrategy, "wins", - "gamesPlayed", + ); + } + case "clutchRate": { + // Clutch rate = win rate in games where you lost your bed. Every loss + // loses your bed, so bed-loss games = bedsLost and clutch wins = + // bedsLost - losses. The dividend is synthetic (no stored stat and no + // counter fallback), so it passes null for the zero-divisor case. + return computeQuotientProgression( + trackingHistory, + trackingEnd, + stat, + (playerData) => + getStat(playerData, gamemode, "bedsLost") - + getStat(playerData, gamemode, "losses"), + (playerData) => getStat(playerData, gamemode, "bedsLost"), gamemode, milestoneStrategy, + null, ); } case "index": { diff --git a/src/stats/progression.unit.test.ts b/src/stats/progression.unit.test.ts index 02e842b2..27be4907 100644 --- a/src/stats/progression.unit.test.ts +++ b/src/stats/progression.unit.test.ts @@ -47,6 +47,7 @@ class StatsBuilder { | "bblr" | "wlr" | "winrate" + | "clutchRate" | "index" | "stars" | "experience" @@ -61,6 +62,7 @@ class StatsBuilder { | "bblr" | "wlr" | "winrate" + | "clutchRate" | "index" | "stars" | "experience" @@ -1169,6 +1171,221 @@ describe("computeStatProgression - winrate stat", () => { } }); +describe("computeStatProgression - clutch rate stat", () => { + // Clutch rate = win rate in games where you lost your bed. Every loss loses + // your bed, so bed-loss games = bedsLost and clutch wins = bedsLost - losses; + // hence clutchRate = (bedsLost - losses) / bedsLost, a fraction in [0, 1] + // whose milestones step by 5% (same family as winrate). The dividend is + // synthetic (bedsLost - losses), so it is NOT a settable stat and is built + // from `bedsLost` + `losses`. + const gamemodes = ALL_GAMEMODE_KEYS; + + const clutchHistory = ( + gamemode: GamemodeKey, + start: { readonly bedsLost: number; readonly losses: number }, + end: { readonly bedsLost: number; readonly losses: number }, + startDate: Date, + endDate: Date, + ): History => [ + new PlayerDataBuilder(TEST_UUID, startDate) + .withGamemodeStats( + gamemode, + new StatsBuilder() + .withStat("bedsLost", start.bedsLost) + .withStat("losses", start.losses) + .build(), + ) + .build(), + new PlayerDataBuilder(TEST_UUID, endDate) + .withGamemodeStats( + gamemode, + new StatsBuilder() + .withStat("bedsLost", end.bedsLost) + .withStat("losses", end.losses) + .build(), + ) + .build(), + ]; + + for (const gamemode of gamemodes) { + describe(`gamemode: ${gamemode}`, () => { + test("improving clutch rate steps up by 5%", () => { + const startDate = new Date("2024-01-01T00:00:00Z"); + const endDate = new Date("2024-01-11T00:00:00Z"); // 10 days + + // Clutch wins = bedsLost - losses. + // Start (100-50)/100 = 0.5, end (200-60)/200 = 0.7, + // session (100-10)/100 = 0.9. + const result = computeStatProgression( + clutchHistory( + gamemode, + { bedsLost: 100, losses: 50 }, + { bedsLost: 200, losses: 60 }, + startDate, + endDate, + ), + endDate, + "clutchRate", + gamemode, + ); + + if (result.error) { + expect.unreachable( + `Expected success but got error: ${result.reason}`, + ); + } + + const { + nextMilestoneValue, + daysUntilMilestone, + progressPerDay, + ...rest + } = result; + + expect(rest).toStrictEqual({ + stat: "clutchRate", + endValue: 140 / 200, + sessionQuotient: 90 / 100, + dividendPerDay: 90 / 10, + divisorPerDay: 100 / 10, + trendingUpward: true, + trackingDataTimeInterval: { + start: startDate, + end: endDate, + }, + }); + + // 0.70 -> 0.75 (next 5% step up). + expect(nextMilestoneValue).toBeCloseTo(0.75, 9); + // t = (M*d0 - k0) / (k - M*d) = (0.75*200 - 140) / (9 - 0.75*10) + expect(daysUntilMilestone).toBeCloseTo(20 / 3, 9); + expect(progressPerDay).toBeCloseTo((0.75 - 140 / 200) / (20 / 3), 9); + }); + + test("declining clutch rate steps down by 5%", () => { + const startDate = new Date("2024-01-01T00:00:00Z"); + const endDate = new Date("2024-01-11T00:00:00Z"); // 10 days + + // Clutch wins = bedsLost - losses. + // Start (200-40)/200 = 0.8, end (250-75)/250 = 0.7, + // session (50-35)/50 = 0.3. + const result = computeStatProgression( + clutchHistory( + gamemode, + { bedsLost: 200, losses: 40 }, + { bedsLost: 250, losses: 75 }, + startDate, + endDate, + ), + endDate, + "clutchRate", + gamemode, + ); + + if (result.error) { + expect.unreachable( + `Expected success but got error: ${result.reason}`, + ); + } + + const { + nextMilestoneValue, + daysUntilMilestone, + progressPerDay, + ...rest + } = result; + + expect(rest).toStrictEqual({ + stat: "clutchRate", + endValue: 175 / 250, + sessionQuotient: 15 / 50, + dividendPerDay: 15 / 10, + divisorPerDay: 50 / 10, + trendingUpward: false, + trackingDataTimeInterval: { + start: startDate, + end: endDate, + }, + }); + + // 0.70 -> 0.65 (next 5% step down). + expect(nextMilestoneValue).toBeCloseTo(0.65, 9); + // t = (M*d0 - k0) / (k - M*d) = (0.65*250 - 175) / (1.5 - 0.65*5) + expect(daysUntilMilestone).toBeCloseTo(50 / 7, 9); + expect(progressPerDay).toBeCloseTo((0.65 - 175 / 250) / (50 / 7), 9); + }); + + test("clutching every bed loss clamps the milestone to 100% and reports it reached", () => { + const startDate = new Date("2024-01-01T00:00:00Z"); + const endDate = new Date("2024-01-11T00:00:00Z"); // 10 days + + // Won every game after losing the bed (losses stay 0): clutch wins + // == bedsLost, so 50/50 -> 100/100 = 100%. The 5% step would be + // 105%, but it clamps to 100% (== the current value), so there is + // no further milestone: reached now (0 days), no NaN. + const result = computeStatProgression( + clutchHistory( + gamemode, + { bedsLost: 50, losses: 0 }, + { bedsLost: 100, losses: 0 }, + startDate, + endDate, + ), + endDate, + "clutchRate", + gamemode, + ); + + if (result.error) { + expect.unreachable( + `Expected success but got error: ${result.reason}`, + ); + } + + expect(result).toStrictEqual({ + stat: "clutchRate", + endValue: 1, + nextMilestoneValue: 1, + daysUntilMilestone: 0, + progressPerDay: 0, + sessionQuotient: 1, + dividendPerDay: 50 / 10, + divisorPerDay: 50 / 10, + trendingUpward: true, + trackingDataTimeInterval: { + start: startDate, + end: endDate, + }, + }); + }); + + test("never losing a bed has no clutch data to project", () => { + const startDate = new Date("2024-01-01T00:00:00Z"); + const endDate = new Date("2024-01-11T00:00:00Z"); // 10 days + + // bedsLost stays 0 (divisor 0 at end and across the session): no + // bed-loss games means no clutch data. The synthetic dividend has + // no counter to fall back to, so this reports an error rather than + // dividing by zero (mirrors winrate with zero games played). + const result = computeStatProgression( + clutchHistory( + gamemode, + { bedsLost: 0, losses: 0 }, + { bedsLost: 0, losses: 0 }, + startDate, + endDate, + ), + endDate, + "clutchRate", + gamemode, + ); + + expect(result.error).toBe(true); + }); + }); + } +}); + describe("computeStatProgression - stars/experience stat", () => { const gamemodes = ALL_GAMEMODE_KEYS; @@ -2446,6 +2663,36 @@ describe(nextNaturalMilestone, () => { expected: 0, }, + // Clutch rate shares winrate's 5%-step, [0, 1]-clamped milestone family. + { + name: "clutchRate: up from mid-step", + value: 0.72, + trendingUpward: true, + stat: "clutchRate", + expected: 0.75, + }, + { + name: "clutchRate: down from mid-step", + value: 0.68, + trendingUpward: false, + stat: "clutchRate", + expected: 0.65, + }, + { + name: "clutchRate: clamps up to 100% at 100% (returns value)", + value: 1, + trendingUpward: true, + stat: "clutchRate", + expected: 1, + }, + { + name: "clutchRate: clamps down to 0% at 0% (returns value)", + value: 0, + trendingUpward: false, + stat: "clutchRate", + expected: 0, + }, + // Everything else: round number scaled to the order of magnitude. { name: "index: up small", @@ -2541,6 +2788,7 @@ describe(nextNaturalMilestone, () => { { value: 3.5, stat: "fkdr" }, { value: 4, stat: "kdr" }, { value: 0.5, stat: "winrate" }, + { value: 0.5, stat: "clutchRate" }, { value: 16, stat: "index" }, { value: 100, stat: "wins" }, { value: 250, stat: "experience" },