elimination announcements #114

Merged
chrisp merged 2 commits from claude/elimination-announcements-g88ixv into main 2026-06-28 19:36:34 +00:00
4 changed files with 188 additions and 4 deletions
Showing only changes of commit c7dcbb0682 - Show all commits

View file

@ -9,7 +9,7 @@ import {
} from "./scoring-rules";
import { getSeasonResults } from "./participant-season-result";
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord";
import { sendStandingsUpdateNotification, type ScoredMatch, type EliminatedTeam } from "~/services/discord";
import { BRACKET_TEMPLATES, type BracketRound } from "~/lib/bracket-templates";
import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff-match";
import { getUserDisplayName } from "~/models/user";
@ -1657,7 +1657,7 @@ export async function recalculateStandings(
export async function recalculateAffectedLeagues(
sportsSeasonId: string,
providedDb?: ReturnType<typeof database>,
options?: { eventName?: string; eventId?: string; matchIds?: string[]; skipDiscord?: boolean }
options?: { eventName?: string; eventId?: string; matchIds?: string[]; skipDiscord?: boolean; eliminatedParticipantIds?: string[] }
): Promise<void> {
const db = providedDb || database();
@ -1726,6 +1726,18 @@ export async function recalculateAffectedLeagues(
for (const r of loserResults) finalizedLoserIds.add(r.participantId);
}
// Look up names for participants eliminated by this action (e.g. bracket/knockout
// generation). These produce no score delta, so they're surfaced via a dedicated
// "Eliminated" section rather than the match/standings-change sections.
const eliminatedIds = options?.eliminatedParticipantIds ?? [];
const eliminatedNameById = new Map<string, string>();
if (eliminatedIds.length > 0) {
const eliminatedParticipants = await db.query.seasonParticipants.findMany({
where: inArray(schema.seasonParticipants.id, eliminatedIds),
});
for (const p of eliminatedParticipants) eliminatedNameById.set(p.id, p.name);
}
// Recalculate each affected season
for (const seasonId of seasonIds) {
// Capture standings before recalculation for delta calculation
@ -1853,11 +1865,38 @@ export async function recalculateAffectedLeagues(
}
}
// Skip notification if scores didn't change AND no match has a displayable username.
// Build the eliminated-teams section: drafted participants in this league that
// were knocked out by this action (bracket/knockout generation). These have a
// 0-pt result and no match, so they only surface here.
let eliminatedTeams: EliminatedTeam[] | undefined;
if (eliminatedIds.length > 0) {
const draftPicks = await db.query.draftPicks.findMany({
where: eq(schema.draftPicks.seasonId, seasonId),
});
const teamIdByParticipantId = new Map(
draftPicks.map((p) => [p.participantId, p.teamId])
);
const teams = eliminatedIds
.filter((id) => teamIdByParticipantId.has(id))
.map((id) => {
const teamId = teamIdByParticipantId.get(id);
const ownerId = teamId ? ownerIdByTeamId.get(teamId) : undefined;
return {
participantName: eliminatedNameById.get(id) ?? "Unknown",
username: ownerId ? usernameByUserId.get(ownerId) : undefined,
discordUserId: ownerId ? discordIdByUserId.get(ownerId) : undefined,
};
});
if (teams.length > 0) eliminatedTeams = teams;
}
// Skip notification if scores didn't change AND no match has a displayable
// username AND there's nothing eliminated to announce.
const hasScoredMatchesToShow = scoredMatches?.some(
(m) => m.winnerUsername !== undefined || m.loserUsername !== undefined
);
if (!hasChanges && !hasScoredMatchesToShow) continue;
const hasEliminatedToShow = (eliminatedTeams?.length ?? 0) > 0;
if (!hasChanges && !hasScoredMatchesToShow && !hasEliminatedToShow) continue;
const standings = afterStandings.map((s) => ({
teamId: s.teamId,
@ -1878,6 +1917,7 @@ export async function recalculateAffectedLeagues(
sportName,
eventName: options?.eventName,
scoredMatches,
eliminatedTeams,
});
} catch (err) {
// Log but don't fail the scoring pipeline if Discord is unreachable

View file

@ -303,9 +303,18 @@ export async function action({ request, params }: Route.ActionArgs) {
// Create a set of participants in the bracket for fast lookup
const participantsInBracket = new Set(participantIds);
// Participants that already had a result before this run — used to scope
// the Discord announcement to teams newly eliminated by this action.
const existingResults = await findParticipantResultsBySportsSeasonId(params.id);
const alreadyHadResult = new Set(existingResults.map((r) => r.participantId));
// Mark participants NOT in the bracket as eliminated
const newlyEliminatedIds: string[] = [];
for (const participant of allParticipants) {
if (!participantsInBracket.has(participant.id)) {
if (!alreadyHadResult.has(participant.id)) {
newlyEliminatedIds.push(participant.id);
}
await setParticipantResult(
participant.id,
event.sportsSeasonId,
@ -315,6 +324,16 @@ export async function action({ request, params }: Route.ActionArgs) {
}
logger.log(`[BracketGeneration] Marked ${allParticipants.length - participantIds.length} participants as eliminated`);
// Announce eliminations to league Discord channels (fantasy events only;
// QPs like tennis/CS2 majors don't get elimination announcements).
if (!event.isQualifyingEvent && newlyEliminatedIds.length > 0) {
await recalculateAffectedLeagues(event.sportsSeasonId, database(), {
eventId: event.id,
eventName: event.name ?? undefined,
eliminatedParticipantIds: newlyEliminatedIds,
});
}
}
// Update the event to store the template ID, scoring start round, and region config
@ -986,14 +1005,30 @@ export async function action({ request, params }: Route.ActionArgs) {
// Eliminate participants from this sport season who are not in any group
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
const existingResults = await findParticipantResultsBySportsSeasonId(params.id);
const alreadyHadResult = new Set(existingResults.map((r) => r.participantId));
const newlyEliminatedIds: string[] = [];
let eliminatedCount = 0;
for (const participant of allParticipants) {
if (!uniqueParticipants.has(participant.id)) {
if (!alreadyHadResult.has(participant.id)) {
newlyEliminatedIds.push(participant.id);
}
await setParticipantResult(participant.id, params.id, 0);
eliminatedCount++;
}
}
// Announce eliminations to league Discord channels (fantasy events only).
const groupsEvent = await getScoringEventById(params.eventId);
if (groupsEvent && !groupsEvent.isQualifyingEvent && newlyEliminatedIds.length > 0) {
await recalculateAffectedLeagues(groupsEvent.sportsSeasonId, database(), {
eventId: groupsEvent.id,
eventName: groupsEvent.name ?? undefined,
eliminatedParticipantIds: newlyEliminatedIds,
});
}
return {
success: `Groups and knockout bracket structure created successfully${eliminatedCount > 0 ? ` (${eliminatedCount} participant(s) not in any group marked as eliminated)` : ""}`,
};
@ -1119,7 +1154,13 @@ export async function action({ request, params }: Route.ActionArgs) {
// Mark eliminated group participants with finalPosition = 0
const eliminatedIds = await getEliminatedParticipantIds(params.eventId);
const existingResults = await findParticipantResultsBySportsSeasonId(params.id);
const alreadyHadResult = new Set(existingResults.map((r) => r.participantId));
const newlyEliminatedIds: string[] = [];
for (const participantId of eliminatedIds) {
if (!alreadyHadResult.has(participantId)) {
newlyEliminatedIds.push(participantId);
}
await setParticipantResult(participantId, event.sportsSeasonId, 0);
}
@ -1127,6 +1168,16 @@ export async function action({ request, params }: Route.ActionArgs) {
`[PopulateKnockout] Assigned 32 participants to knockout, marked ${eliminatedIds.length} as eliminated`
);
// Announce group-stage eliminations to league Discord channels (fantasy
// events only; QPs don't get elimination announcements).
if (!event.isQualifyingEvent && newlyEliminatedIds.length > 0) {
await recalculateAffectedLeagues(event.sportsSeasonId, database(), {
eventId: event.id,
eventName: event.name ?? undefined,
eliminatedParticipantIds: newlyEliminatedIds,
});
}
return { success: "Knockout bracket populated successfully" };
} catch (error) {
logger.error("Error populating knockout:", error);

View file

@ -441,6 +441,70 @@ describe("sendStandingsUpdateNotification", () => {
expect(desc).not.toContain("Alpha");
expect(desc).not.toContain("Beta");
});
it("lists eliminated teams with their managers", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
previousStandings: new Map([["a", 100]]),
eliminatedTeams: [
{ participantName: "Phoenix Suns", username: "manager1" },
{ participantName: "Toronto Raptors" },
],
});
const desc = getDescription();
expect(desc).toContain("**Eliminated**");
expect(desc).toContain("• **Phoenix Suns (manager1)**");
expect(desc).toContain("• **Toronto Raptors**");
});
it("uses Discord mention for opted-in eliminated managers and pings them", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
previousStandings: new Map([["a", 100]]),
eliminatedTeams: [
{ participantName: "Phoenix Suns", username: "manager1", discordUserId: "555" },
],
});
const desc = getDescription();
expect(desc).toContain("• **Phoenix Suns (<@555>)**");
expect(desc).not.toContain("manager1");
const payload = getPayload();
expect(payload.content).toBe("<@555>");
expect(payload.allowed_mentions).toEqual({ parse: [], users: ["555"] });
});
it("escapes Discord markdown in eliminated team names", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 100, rank: 1 }],
previousStandings: new Map([["a", 100]]),
eliminatedTeams: [{ participantName: "Team__Bold", username: "user_name" }],
});
const desc = getDescription();
expect(desc).toContain("Team\\_\\_Bold");
expect(desc).toContain("user\\_name");
});
it("omits the eliminated section when none provided", async () => {
await sendStandingsUpdateNotification({
webhookUrl: WEBHOOK_URL,
seasonName: "My League 2025",
standings: [{ teamId: "a", teamName: "Alpha", totalPoints: 150, rank: 1 }],
previousStandings: new Map([["a", 125]]),
});
const desc = getDescription();
expect(desc).not.toContain("**Eliminated**");
});
});
describe("sendDraftOrderNotification", () => {

View file

@ -81,6 +81,12 @@ export interface ScoredMatch {
loserDiscordUserId?: string;
}
export interface EliminatedTeam {
participantName: string;
username?: string;
discordUserId?: string;
}
export async function sendStandingsUpdateNotification({
webhookUrl,
seasonName,
@ -90,6 +96,7 @@ export async function sendStandingsUpdateNotification({
sportName,
eventName,
scoredMatches,
eliminatedTeams,
}: {
webhookUrl: string;
seasonName: string;
@ -99,6 +106,7 @@ export async function sendStandingsUpdateNotification({
sportName?: string;
eventName?: string;
scoredMatches?: ScoredMatch[];
eliminatedTeams?: EliminatedTeam[];
}): Promise<void> {
const sections: string[] = [];
@ -136,6 +144,24 @@ export async function sendStandingsUpdateNotification({
}
}
// 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}`);
@ -195,6 +221,9 @@ export async function sendStandingsUpdateNotification({
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 = {