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
18 changes: 17 additions & 1 deletion src/routes/session/$uuid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] => {
Expand All @@ -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"];
}
Expand Down Expand Up @@ -969,6 +974,16 @@ const ProgressionCaption: React.FC<ProgressionCaptionProps> = ({ progression })
</Typography>
);
}
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 (
<Typography variant="caption">
{`${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)`}
</Typography>
);
}
case "index": {
return (
<Typography variant="caption">
Expand Down Expand Up @@ -1258,6 +1273,7 @@ function RouteComponent() {
"bblr",
"wlr",
"winrate",
"clutchRate",
];

return (
Expand Down
1 change: 1 addition & 0 deletions src/stats/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const STAT_FORMAT_KIND: Record<StatKey, StatFormatKind> = {
bblr: "decimal",
wlr: "decimal",
winrate: "percentage",
clutchRate: "percentage",
index: "integer",
};

Expand Down
27 changes: 27 additions & 0 deletions src/stats/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 0 additions & 2 deletions src/stats/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,7 @@ export const GAMEMODE_STAT_KEYS = [
"wlr",
"winrate",
"index",
/*
"clutchRate",
*/
] as const;
export type GamemodeStatKey = (typeof GAMEMODE_STAT_KEYS)[number];

Expand Down
6 changes: 6 additions & 0 deletions src/stats/labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)";
}
Expand Down Expand Up @@ -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";
}
Expand Down
91 changes: 67 additions & 24 deletions src/stats/progression.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -199,7 +201,10 @@ type IndexProgression = BaseStatProgression & {

export type StatProgression =
| (BaseStatProgression & {
stat: Exclude<StatKey, "fkdr" | "kdr" | "bblr" | "wlr" | "winrate" | "index">;
stat: Exclude<
StatKey,
"fkdr" | "kdr" | "bblr" | "wlr" | "winrate" | "clutchRate" | "index"
>;
})
| QuotientProgression
| IndexProgression;
Expand All @@ -208,23 +213,31 @@ const computeQuotientProgression = (
trackingHistory: History,
trackingEnd: Date,
stat: QuotientProgression["stat"],
dividendStat: Exclude<StatKey, "winstreak">,
divisorStat: Exclude<StatKey, "winstreak">,
// 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<StatKey, "winstreak"> | null,
): QuotientProgression | { error: true; reason: string } => {
const [start, end] = trackingHistory;
const startDate = start.queriedAt;
const endDate = trackingEnd;
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;
Expand All @@ -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,
);
Expand Down Expand Up @@ -438,54 +458,77 @@ export const computeStatProgression = (
trackingHistory,
trackingEnd,
stat,
"finalKills",
"finalDeaths",
(playerData) => getStat(playerData, gamemode, "finalKills"),
(playerData) => getStat(playerData, gamemode, "finalDeaths"),
gamemode,
milestoneStrategy,
"finalKills",
);
}
case "kdr": {
return computeQuotientProgression(
trackingHistory,
trackingEnd,
stat,
"kills",
"deaths",
(playerData) => getStat(playerData, gamemode, "kills"),
(playerData) => getStat(playerData, gamemode, "deaths"),
gamemode,
milestoneStrategy,
"kills",
);
}
case "bblr": {
return computeQuotientProgression(
trackingHistory,
trackingEnd,
stat,
"bedsBroken",
"bedsLost",
(playerData) => getStat(playerData, gamemode, "bedsBroken"),
(playerData) => getStat(playerData, gamemode, "bedsLost"),
gamemode,
milestoneStrategy,
"bedsBroken",
);
}
case "wlr": {
return computeQuotientProgression(
trackingHistory,
trackingEnd,
stat,
"wins",
"losses",
(playerData) => getStat(playerData, gamemode, "wins"),
(playerData) => getStat(playerData, gamemode, "losses"),
gamemode,
milestoneStrategy,
"wins",
);
}
case "winrate": {
return computeQuotientProgression(
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": {
Expand Down
Loading