brackt/app/services/discord.ts
Claude efec504c08
Fix qualifying points scoring, Discord rounding, and majors count
Four related bugs in the Qualifying Points subsystem surfaced on Wimbledon:

1. Some leagues stored 2 QP instead of 1.5 for Round-of-16 losers. Sibling/
   mirror windows scored via the placement-group path in processQualifyingEvent,
   which took the tie span from the players present on that window's roster
   (a draftable subset) instead of the full field. A window holding fewer than
   the 8 tied R16 losers split the 9-16 points too few ways and over-awarded.
   The tie span is now derived from the canonical tournament_results (the whole
   field) for tournament-linked events, falling back to the live count only for
   standalone events. Every league now scores identically.

2. Discord notifications rounded QP with Math.round, turning 1.5 into "2".
   Both the "Points Awarded" and "QP Standings" values now use a 2-decimal
   formatter mirroring the web UI's formatQP, so Discord and the site agree.

3. The Discord "QP Standings" block ranked players only among that event's
   scorers, showing two R16 losers as T1 instead of T9. Rank is now computed
   over the full season field and passed through to the notifier.

4. "N of M majors completed" reached "11 of 4": majorsCompleted was a stored
   counter incremented on every fan-out sync with a guard that misfired. It is
   now derived on read (getMajorsCompleted = count of completed qualifying
   events), which is self-correcting; the increment/decrement writes are removed
   along with the now-unused hasProcessedQualifyingPlacement helper.

Adds scripts/backfill-qp-resplit.ts to silently re-score existing majors so
already-corrupted seasons are corrected (2 -> 1.5), and threads a
skipNotifications option through processQualifyingEvent / syncTournamentResults
so the backfill does not re-ping every league.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017AUcHy9m7aF6axsY6Grpe4
2026-07-03 20:40:47 +00:00

510 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { buildTiedRankChecker } from "~/lib/standings-display";
interface DiscordEmbed {
title?: string;
url?: string;
description?: string;
color?: number;
footer?: { text: string };
}
interface DiscordWebhookPayload {
content?: string;
embeds?: DiscordEmbed[];
allowed_mentions?: { parse: string[]; users: string[] };
}
// Per-league serial promise chain: serializes pick notifications within a league to
// respect Discord's per-webhook rate limit (~5 req/2s) during autodraft chains.
const leagueChains = new Map<string, Promise<void>>();
export function enqueuePickNotification(leagueId: string, fn: () => Promise<void>): void {
const prev = leagueChains.get(leagueId) ?? Promise.resolve();
const next = prev.then(() => fn().catch(() => {}));
leagueChains.set(leagueId, next);
// Clean up the map entry once the chain settles, but only if no newer pick
// was enqueued after this one (reference equality guards against that race).
next.then(() => { if (leagueChains.get(leagueId) === next) leagueChains.delete(leagueId); });
}
export async function sendDiscordWebhook(
webhookUrl: string,
payload: DiscordWebhookPayload
): Promise<void> {
const body = JSON.stringify(payload);
const headers = { "Content-Type": "application/json" };
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch(webhookUrl, { method: "POST", headers, body });
if (res.status === 429) {
// Cap the total wait at 10 s so awaited callers (e.g. settings actions) are
// never hung for a full Discord global-rate-limit window. Use || 1 instead of
// ?? 1 so NaN (non-numeric header) and 0 both fall back to a 1-second default.
// Multiply by (attempt + 1) for progressive backoff, then cap the product.
const wait = Math.min((Number(res.headers.get("Retry-After")) || 1) * (attempt + 1), 10);
await new Promise((r) => setTimeout(r, wait * 1000));
continue;
}
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Discord webhook failed: ${res.status} ${text}`);
}
return;
}
throw new Error("Discord webhook failed after 3 retries (rate limited)");
}
/** Escape characters that trigger Discord markdown formatting. */
function escapeMarkdown(text: string): string {
return text.replace(/[_*~`|\\]/g, "\\$&");
}
/**
* Format a QP value for display. QP is genuinely fractional (e.g. a tennis R16 loser
* earns 1.5 QP from the 916 split), so we must NOT round: show whole numbers plainly
* and fractional values to 2 decimals. Mirrors the web UI's formatQP
* (app/components/scoring/QualifyingPointsStandings.tsx) so Discord and the site agree.
*/
function formatQPValue(n: number): string {
return n % 1 === 0 ? n.toString() : n.toFixed(2);
}
export interface StandingEntry {
teamId: string;
teamName: string;
username?: string;
discordUserId?: string;
totalPoints: number;
rank: number;
}
export interface ScoredMatch {
winnerName: string;
loserName: string;
winnerUsername?: string;
loserUsername?: string;
winnerDiscordUserId?: string;
loserDiscordUserId?: string;
}
export interface EliminatedTeam {
participantName: string;
username?: string;
discordUserId?: string;
}
export async function sendStandingsUpdateNotification({
webhookUrl,
seasonName,
standings,
previousStandings,
previousRanks,
sportName,
eventName,
scoredMatches,
eliminatedTeams,
}: {
webhookUrl: string;
seasonName: string;
standings: StandingEntry[];
previousStandings: Map<string, number>;
previousRanks?: Map<string, number>;
sportName?: string;
eventName?: string;
scoredMatches?: ScoredMatch[];
eliminatedTeams?: EliminatedTeam[];
}): Promise<void> {
const sections: string[] = [];
// Header: "Sport Name — Event Name"
if (sportName || eventName) {
const parts = [sportName, eventName].filter(Boolean);
sections.push(`**${parts.join(" — ")}**`);
}
// Scored matches section — only show matches where at least one manager
// (fantasy team owner) scored Brackt points or had a team eliminated.
const relevantMatches = scoredMatches?.filter(
(m) => m.winnerUsername !== undefined || m.loserUsername !== undefined
);
if (relevantMatches && relevantMatches.length > 0) {
sections.push("\n**Scored Matches**");
for (const match of relevantMatches) {
const winnerManagerLabel = match.winnerDiscordUserId
? `<@${match.winnerDiscordUserId}>`
: match.winnerUsername
? escapeMarkdown(match.winnerUsername)
: undefined;
const loserManagerLabel = match.loserDiscordUserId
? `<@${match.loserDiscordUserId}>`
: match.loserUsername
? escapeMarkdown(match.loserUsername)
: undefined;
const winnerLabel = winnerManagerLabel
? `${escapeMarkdown(match.winnerName)} (${winnerManagerLabel})`
: escapeMarkdown(match.winnerName);
const loserLabel = loserManagerLabel
? `${escapeMarkdown(match.loserName)} (${loserManagerLabel})`
: escapeMarkdown(match.loserName);
sections.push(`• **${winnerLabel}** def. ${loserLabel}`);
}
}
// Eliminated teams section — drafted teams knocked out when a bracket/knockout
// was generated (e.g. NBA teams that missed the playoffs, FIFA group-stage
// losers). These produce no score change, so they wouldn't appear above.
if (eliminatedTeams && eliminatedTeams.length > 0) {
sections.push("\n**Eliminated**");
for (const team of eliminatedTeams) {
const managerLabel = team.discordUserId
? `<@${team.discordUserId}>`
: team.username
? escapeMarkdown(team.username)
: undefined;
const label = managerLabel
? `${escapeMarkdown(team.participantName)} (${managerLabel})`
: escapeMarkdown(team.participantName);
sections.push(`• **${label}**`);
}
}
const isTied = buildTiedRankChecker(standings.map((s) => s.rank));
const rankLabel = (rank: number) => (isTied(rank) ? `T${rank}` : `${rank}`);
// Standings changes section — show teams whose points or rank changed.
const changedTeams = standings.filter((s) => {
const prevPoints = previousStandings.get(s.teamId);
const pointsChanged = prevPoints !== undefined && prevPoints !== s.totalPoints;
const prevRank = previousRanks?.get(s.teamId);
const rankChanged = prevRank !== undefined && prevRank !== s.rank;
return pointsChanged || rankChanged;
});
if (changedTeams.length > 0) {
sections.push("\n**Standings Changes**");
for (const s of changedTeams) {
const rankPrefix = rankLabel(s.rank);
const prevPoints = previousStandings.get(s.teamId);
let pointDelta = "";
if (prevPoints !== undefined && prevPoints !== s.totalPoints) {
const diff = Math.round(s.totalPoints - prevPoints);
const sign = diff > 0 ? "+" : "";
pointDelta = ` **(${sign}${diff} pts)**`;
}
let rankDelta = "";
if (previousRanks) {
const prevRank = previousRanks.get(s.teamId);
if (prevRank !== undefined && prevRank !== s.rank) {
const moved = prevRank - s.rank; // positive = moved up
rankDelta = moved > 0 ? `${moved}` : `${Math.abs(moved)}`;
}
}
const escapedName = escapeMarkdown(s.teamName);
const managerLabel = s.discordUserId
? `<@${s.discordUserId}>`
: s.username
? escapeMarkdown(s.username)
: undefined;
const label = managerLabel ? `${escapedName} (${managerLabel})` : escapedName;
sections.push(`${rankPrefix}\\. ${label}${Math.round(s.totalPoints)} pts${pointDelta}${rankDelta}`);
}
}
const MAX_DESCRIPTION = 4096;
let description = sections.join("\n");
if (description.length > MAX_DESCRIPTION) {
description = description.slice(0, MAX_DESCRIPTION - 3) + "...";
}
// Collect Discord user IDs of all opted-in managers appearing in this notification.
const pingUserIds = new Set<string>();
for (const s of changedTeams) {
if (s.discordUserId) pingUserIds.add(s.discordUserId);
}
for (const m of relevantMatches ?? []) {
if (m.winnerDiscordUserId) pingUserIds.add(m.winnerDiscordUserId);
if (m.loserDiscordUserId) pingUserIds.add(m.loserDiscordUserId);
}
for (const t of eliminatedTeams ?? []) {
if (t.discordUserId) pingUserIds.add(t.discordUserId);
}
const pingIds = [...pingUserIds];
const payload: DiscordWebhookPayload = {
embeds: [
{
title: `📊 Standings Update — ${seasonName}`,
description,
color: 0xffd700, // Gold
footer: { text: "brackt.com" },
},
],
};
if (pingIds.length > 0) {
// Discord caps allowed_mentions.users at 100; slice to avoid a rejected payload.
const cappedIds = pingIds.slice(0, 100);
payload.content = cappedIds.map((id) => `<@${id}>`).join(" ");
payload.allowed_mentions = { parse: [], users: cappedIds };
}
await sendDiscordWebhook(webhookUrl, payload);
}
export interface QPEventEntry {
participantName: string;
qpEarned: number;
qpTotal: number;
/**
* The participant's rank in the FULL season QP standings (all participants), not
* their position among this event's scorers. Computed by the caller so the "QP
* Standings" block reflects the whole sport season — e.g. two R16 losers on 1.5 QP
* show as T9 (8 players ahead) rather than T1 among just the two of them.
*/
globalRank: number;
/** True when another participant in the full field shares this globalRank. */
globalRankTied: boolean;
ownerUsername?: string;
ownerDiscordUserId?: string;
}
/** A drafted player knocked out this sync in a non-scoring round (0 QP). */
export interface QPEliminatedEntry {
participantName: string;
ownerUsername?: string;
ownerDiscordUserId?: string;
}
export async function sendQualifyingPointsUpdateNotification({
webhookUrl,
seasonName,
sportName,
eventName,
entries,
eliminated = [],
}: {
webhookUrl: string;
seasonName: string;
sportName?: string;
eventName?: string;
entries: QPEventEntry[];
eliminated?: QPEliminatedEntry[];
}): Promise<void> {
if (entries.length === 0 && eliminated.length === 0) return;
const sections: string[] = [];
if (sportName || eventName) {
const parts = [sportName, eventName].filter(Boolean);
sections.push(`**${parts.join(" — ")}**`);
}
const awardedEntries = entries
.filter((e) => e.qpEarned > 0)
.toSorted((a, b) => b.qpEarned - a.qpEarned);
if (awardedEntries.length > 0) {
sections.push("\n**Points Awarded**");
for (const e of awardedEntries) {
const managerLabel = e.ownerDiscordUserId
? `<@${e.ownerDiscordUserId}>`
: e.ownerUsername
? escapeMarkdown(e.ownerUsername)
: undefined;
const label = managerLabel
? `${escapeMarkdown(e.participantName)} (${managerLabel})`
: escapeMarkdown(e.participantName);
sections.push(`• **${label}** — +${formatQPValue(e.qpEarned)} QP`);
}
}
const zeroEntries = entries.filter((e) => e.qpEarned === 0);
if (zeroEntries.length > 0) {
sections.push("\n**No Points**");
for (const e of zeroEntries) {
const managerLabel = e.ownerDiscordUserId
? `<@${e.ownerDiscordUserId}>`
: e.ownerUsername
? escapeMarkdown(e.ownerUsername)
: undefined;
const label = managerLabel
? `${escapeMarkdown(e.participantName)} (${managerLabel})`
: escapeMarkdown(e.participantName);
sections.push(`${label}`);
}
}
// Knocked-out section — drafted players eliminated this sync in a non-scoring
// round. They earn no QP, so they'd otherwise never be surfaced to their manager.
if (eliminated.length > 0) {
sections.push("\n**Knocked Out**");
for (const e of eliminated) {
const managerLabel = e.ownerDiscordUserId
? `<@${e.ownerDiscordUserId}>`
: e.ownerUsername
? escapeMarkdown(e.ownerUsername)
: undefined;
const label = managerLabel
? `${escapeMarkdown(e.participantName)} (${managerLabel})`
: escapeMarkdown(e.participantName);
sections.push(`${label}`);
}
}
// Order by the participant's rank in the FULL season standings (globalRank),
// supplied by the caller, so the block reads as a season leaderboard slice.
const sorted = [...entries].toSorted((a, b) => a.globalRank - b.globalRank);
// The standings block reflects QP earners; skip it entirely when this sync only
// reported knockouts (no QP change) so we don't emit an empty header.
if (sorted.length > 0) {
sections.push("\n**QP Standings**");
for (const e of sorted) {
const rankPrefix = e.globalRankTied ? `T${e.globalRank}` : `${e.globalRank}`;
const ownerLabel = e.ownerDiscordUserId
? `<@${e.ownerDiscordUserId}>`
: e.ownerUsername
? escapeMarkdown(e.ownerUsername)
: undefined;
const managerLabel = ownerLabel ? ` (${ownerLabel})` : "";
sections.push(`${rankPrefix}\\. ${escapeMarkdown(e.participantName)}${managerLabel}${formatQPValue(e.qpTotal)} QP`);
}
}
const MAX_DESCRIPTION = 4096;
let description = sections.join("\n");
if (description.length > MAX_DESCRIPTION) {
description = description.slice(0, MAX_DESCRIPTION - 3) + "...";
}
const pingUserIds = new Set<string>();
for (const e of [...awardedEntries, ...zeroEntries, ...eliminated]) {
if (e.ownerDiscordUserId) pingUserIds.add(e.ownerDiscordUserId);
}
const pingIds = [...pingUserIds];
const payload: DiscordWebhookPayload = {
embeds: [
{
title: `🏅 Qualifying Points Update — ${seasonName}`,
description,
color: 0x5865f2, // Discord blurple
footer: { text: "brackt.com" },
},
],
};
if (pingIds.length > 0) {
const cappedIds = pingIds.slice(0, 100);
payload.content = cappedIds.map((id) => `<@${id}>`).join(" ");
payload.allowed_mentions = { parse: [], users: cappedIds };
}
await sendDiscordWebhook(webhookUrl, payload);
}
export async function sendPickAnnouncementNotification({
webhookUrl,
draftUrl,
pickNumber,
round,
pickInRound,
pickedTeamName,
participantName,
sportName,
nextTeamName,
nextOwnerDiscordId,
isDraftComplete,
}: {
webhookUrl: string;
draftUrl: string;
pickNumber: number;
round: number;
pickInRound: number;
pickedTeamName: string;
participantName: string;
sportName: string;
nextTeamName?: string;
nextOwnerDiscordId?: string;
isDraftComplete: boolean;
}): Promise<void> {
const lines: string[] = [
`**${escapeMarkdown(pickedTeamName)}** selected **${escapeMarkdown(participantName)}** (${escapeMarkdown(sportName)})`,
];
if (isDraftComplete) {
lines.push("", "The draft is complete!");
} else if (nextTeamName) {
const nextLabel = nextOwnerDiscordId
? `<@${nextOwnerDiscordId}>`
: escapeMarkdown(nextTeamName);
lines.push("", `On the clock: **${escapeMarkdown(nextTeamName)}** (${nextLabel})`);
}
const description = lines.join("\n");
const payload: DiscordWebhookPayload = {
embeds: [
{
title: `Pick #${pickNumber} — Round ${round}, Pick ${pickInRound}`,
url: draftUrl,
description,
color: 0x5865f2,
},
],
};
if (nextOwnerDiscordId && !isDraftComplete) {
payload.content = `<@${nextOwnerDiscordId}>`;
payload.allowed_mentions = { parse: [], users: [nextOwnerDiscordId] };
}
await sendDiscordWebhook(webhookUrl, payload);
}
export async function sendDraftOrderNotification({
webhookUrl,
leagueName,
leagueUrl,
method,
teams,
}: {
webhookUrl: string;
leagueName: string;
leagueUrl: string;
method: "manual" | "randomized";
teams: Array<{ name: string; position: number; username?: string }>;
}): Promise<void> {
const title =
method === "manual"
? `Draft Order Manually Set — ${leagueName}`
: `Draft Order Randomized — ${leagueName}`;
const sorted = teams.toSorted((a, b) => a.position - b.position);
let description = sorted
.map((t) => {
const teamLabel = escapeMarkdown(t.name);
const usernameLabel = t.username ? ` (${escapeMarkdown(t.username)})` : "";
return `${t.position}. ${teamLabel}${usernameLabel}`;
})
.join("\n");
if (description.length > 4096) {
description = description.slice(0, 4093) + "...";
}
await sendDiscordWebhook(webhookUrl, {
embeds: [
{
title,
url: leagueUrl,
description,
color: 0x5865f2,
},
],
});
}