From c7dcbb068246d6a337fd062d2db928a0564bbbfa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 17:37:34 +0000 Subject: [PATCH 1/2] Announce playoff eliminations to Discord on bracket generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teams that miss the playoffs (NBA-style brackets) or are knocked out in the group stage (FIFA group->knockout) were correctly marked eliminated but never announced to league Discord channels — the elimination intents never called recalculateAffectedLeagues, and these 0-pt eliminations have no score delta or match to surface in the existing scored-match/standings sections. Add an 'Eliminated' section to the standings-update embed and feed newly eliminated drafted participants through recalculateAffectedLeagues via a new eliminatedParticipantIds option. Wire it into generate-bracket, generate-groups, and populate-knockout, gated to fantasy events only (qualifying-point majors like tennis/CS2 are excluded) and scoped to participants newly eliminated by the run. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01E5gLy3K9vW65ogG8GTCjWE --- app/models/scoring-calculator.ts | 48 ++++++++++++-- ...sons.$id.events.$eventId.bracket.server.ts | 51 +++++++++++++++ app/services/__tests__/discord.test.ts | 64 +++++++++++++++++++ app/services/discord.ts | 29 +++++++++ 4 files changed, 188 insertions(+), 4 deletions(-) diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index bb28973..339e5c9 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -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, - options?: { eventName?: string; eventId?: string; matchIds?: string[]; skipDiscord?: boolean } + options?: { eventName?: string; eventId?: string; matchIds?: string[]; skipDiscord?: boolean; eliminatedParticipantIds?: string[] } ): Promise { 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(); + 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 diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts index 59b66a2..8d16da0 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts @@ -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); diff --git a/app/services/__tests__/discord.test.ts b/app/services/__tests__/discord.test.ts index f40a4b6..59f0be5 100644 --- a/app/services/__tests__/discord.test.ts +++ b/app/services/__tests__/discord.test.ts @@ -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", () => { diff --git a/app/services/discord.ts b/app/services/discord.ts index 016c064..7e49c88 100644 --- a/app/services/discord.ts +++ b/app/services/discord.ts @@ -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 { 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 = { -- 2.45.3 From f75f3a228ee0d3069debc155d7c7a8d74d55af8c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 18:43:28 +0000 Subject: [PATCH 2/2] Address code review on elimination announcements - Extract markEliminatedAndAnnounce helper, removing the duplicated load-existing-results / collect-newly-eliminated / gated-recalc pattern from the generate-bracket, generate-groups, and populate-knockout intents. - Wrap the Discord announcement in a log-only try/catch so a failed standings recalc/snapshot never fails the (already-committed) bracket generation action. - Drop eventId from the elimination recalc call so announcements don't pull in unrelated completed matches as Scored Matches. - Hoist the per-season draftPicks lookup in recalculateAffectedLeagues so the scored-match and elimination sections share one query. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01E5gLy3K9vW65ogG8GTCjWE --- app/models/scoring-calculator.ts | 28 ++-- ...sons.$id.events.$eventId.bracket.server.ts | 134 ++++++++---------- 2 files changed, 69 insertions(+), 93 deletions(-) diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 339e5c9..4df2bbd 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -1812,6 +1812,19 @@ export async function recalculateAffectedLeagues( afterStandings.map((s) => [s.teamId, s.team.ownerId]) ); + // Shared draft-pick lookup for both the scored-match and elimination sections, + // loaded once per season only when at least one section needs it. + const needDraftPicks = allCompletedMatches.length > 0 || eliminatedIds.length > 0; + const draftPicks = needDraftPicks + ? await db.query.draftPicks.findMany({ + where: eq(schema.draftPicks.seasonId, seasonId), + }) + : []; + const draftedIds = new Set(draftPicks.map((p) => p.participantId)); + const teamIdByParticipantId = new Map( + draftPicks.map((p) => [p.participantId, p.teamId]) + ); + // Build scored matches for the notification. // Winners only appear if their team's score changed (they earned points this round). // Losers appear if their team's score changed OR they were definitively eliminated @@ -1821,11 +1834,6 @@ export async function recalculateAffectedLeagues( // correctly suppressed. let scoredMatches: ScoredMatch[] | undefined; if (allCompletedMatches.length > 0) { - const draftPicks = await db.query.draftPicks.findMany({ - where: eq(schema.draftPicks.seasonId, seasonId), - }); - const draftedIds = new Set(draftPicks.map((p) => p.participantId)); - const relevant = allCompletedMatches.filter( (m) => (m.winnerId && draftedIds.has(m.winnerId)) || @@ -1833,10 +1841,6 @@ export async function recalculateAffectedLeagues( ); if (relevant.length > 0) { - const teamIdByParticipantId = new Map( - draftPicks.map((p) => [p.participantId, p.teamId]) - ); - const lookupOwner = (participantId: string | null | undefined) => { if (!participantId) return undefined; const teamId = teamIdByParticipantId.get(participantId); @@ -1870,12 +1874,6 @@ export async function recalculateAffectedLeagues( // 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) => { diff --git a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts index 8d16da0..cb7aebe 100644 --- a/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts +++ b/app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts @@ -148,6 +148,45 @@ async function scoreQualifyingBracket( await fanOutMajorIfPrimary(event, { markComplete: false }); } +/** + * Mark the given participants as eliminated (finalPosition = 0) and, for fantasy + * (non-qualifying) events, announce the teams newly eliminated by this run to the + * affected leagues' Discord channels. Returns the number of participants marked. + * + * "Newly eliminated" = participants with no prior result row, so re-running a + * generation step never re-announces the same teams. The announcement is a + * best-effort side effect: a failure must not fail the generation action, since + * the eliminations themselves are already committed. eventId is deliberately + * omitted from the recalc call so the announcement doesn't pull in unrelated + * completed matches as "Scored Matches". + */ +async function markEliminatedAndAnnounce( + event: { id: string; name: string | null; sportsSeasonId: string; isQualifyingEvent: boolean }, + participantIds: string[] +): Promise { + const existingResults = await findParticipantResultsBySportsSeasonId(event.sportsSeasonId); + const alreadyHadResult = new Set(existingResults.map((r) => r.participantId)); + const newlyEliminatedIds = participantIds.filter((id) => !alreadyHadResult.has(id)); + + for (const participantId of participantIds) { + await setParticipantResult(participantId, event.sportsSeasonId, 0); + } + + // QPs (e.g. tennis/CS2 majors) don't get elimination announcements. + if (!event.isQualifyingEvent && newlyEliminatedIds.length > 0) { + try { + await recalculateAffectedLeagues(event.sportsSeasonId, database(), { + eventName: event.name ?? undefined, + eliminatedParticipantIds: newlyEliminatedIds, + }); + } catch (err) { + logger.error("[Eliminations] Discord announcement failed:", err); + } + } + + return participantIds.length; +} + export async function action({ request, params }: Route.ActionArgs) { const formData = await request.formData(); const intent = formData.get("intent"); @@ -294,46 +333,16 @@ export async function action({ request, params }: Route.ActionArgs) { try { await generateBracketFromTemplate(params.eventId, templateId, participantIds, regionOverride); - // PHASE 5.3: Mark participants NOT in the bracket as eliminated + // PHASE 5.3: Mark participants NOT in the bracket as eliminated (and announce). const event = await getScoringEventById(params.eventId); if (event) { - // Get all participants in the sports season const allParticipants = await findParticipantsBySportsSeasonId(params.id); - - // 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, - 0 // 0 = didn't make playoffs, eliminated - ); - } - } - - 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, - }); - } + const toEliminate = allParticipants + .filter((p) => !participantsInBracket.has(p.id)) + .map((p) => p.id); + const eliminatedCount = await markEliminatedAndAnnounce(event, toEliminate); + logger.log(`[BracketGeneration] Marked ${eliminatedCount} participants as eliminated`); } // Update the event to store the template ID, scoring start round, and region config @@ -1004,30 +1013,16 @@ export async function action({ request, params }: Route.ActionArgs) { await generateBracketFromTemplate(params.eventId, templateId); // 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). + // (and announce to leagues for fantasy events). 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, - }); + if (!groupsEvent) { + return { error: "Event not found" }; } + const allParticipants = await findParticipantsBySportsSeasonId(params.id); + const toEliminate = allParticipants + .filter((p) => !uniqueParticipants.has(p.id)) + .map((p) => p.id); + const eliminatedCount = await markEliminatedAndAnnounce(groupsEvent, toEliminate); return { success: `Groups and knockout bracket structure created successfully${eliminatedCount > 0 ? ` (${eliminatedCount} participant(s) not in any group marked as eliminated)` : ""}`, @@ -1152,32 +1147,15 @@ export async function action({ request, params }: Route.ActionArgs) { // Assign participants to knockout bracket await assignParticipantsToKnockout(params.eventId, assignments); - // Mark eliminated group participants with finalPosition = 0 + // Mark eliminated group participants with finalPosition = 0 (and announce + // the group-stage eliminations to leagues for fantasy events). 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); - } + await markEliminatedAndAnnounce(event, eliminatedIds); logger.log( `[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); -- 2.45.3