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 5c9f54e..56773d7 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 @@ -146,7 +146,13 @@ async function scoreQualifyingBracket( */ newlyEliminatedParticipantIds?: Set ): Promise { - await processQualifyingBracketEvent(event.id, db); + // processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent), + // recalcs participant QP totals, AND announces the QP change to this window's leagues. + // Calling processQualifyingBracketEvent directly here would score silently — the QP + // Discord notification only fires from processQualifyingEvent. The fan-out below skips + // this (primary) window via skipEventId, so mirror windows are announced separately + // with no double-post. + await processQualifyingEvent(event.id, db, { newlyEliminatedParticipantIds }); await recalculateAffectedLeagues( event.sportsSeasonId, db, @@ -613,9 +619,14 @@ export async function action({ request, params }: Route.ActionArgs) { // previously completed matches in the event. if (successCount > 0) { const db = database(); - // Qualifying majors: derive QP from the full bracket once for the batch. + // Qualifying majors: derive QP from the full bracket once for the batch, and + // announce the QP change to this window's leagues. processQualifyingEvent (not + // processQualifyingBracketEvent) is what sends the QP Discord notification; the + // fan-out below skips this window (skipEventId) so mirrors don't double-post. if (event.isQualifyingEvent) { - await processQualifyingBracketEvent(event.id, db); + await processQualifyingEvent(event.id, db, { + newlyEliminatedParticipantIds: new Set(newlyDecidedLosers(decidedEntries)), + }); } // Update probabilities first so recalculateAffectedLeagues reads fresh EVs // when computing projected points. diff --git a/app/services/__tests__/discord.test.ts b/app/services/__tests__/discord.test.ts index 96ad65d..3b5457c 100644 --- a/app/services/__tests__/discord.test.ts +++ b/app/services/__tests__/discord.test.ts @@ -790,24 +790,68 @@ describe("sendQualifyingPointsUpdateNotification", () => { expect(desc.indexOf("Novak Djokovic")).toBeLessThan(desc.indexOf("Carlos Alcaraz")); }); - it("shows Non-scoring Participants section with season total and plain manager name", async () => { + it("lists non-scoring participants (outside top 8) as one comma-separated line below Top 8", async () => { + // Non-scoring now draws from the full scoreboard: every drafted participant NOT in + // the top 8 (rank 9+ or unranked), rendered on a single line "Name (total, manager)" + // ordered by season total desc — not just this event's zero-earners. await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", - entries: BASE_ENTRIES, + entries: [ + { participantName: "Champ", qpEarned: 10, qpTotal: 100, globalRank: 1, globalRankTied: false, ownerUsername: "alex" }, + ], + scoreboard: [ + { participantName: "Champ", qpEarned: 10, qpTotal: 100, globalRank: 1, globalRankTied: false, ownerUsername: "alex" }, + { participantName: "Also Ran", qpEarned: 0, qpTotal: 5, globalRank: 9, globalRankTied: false, ownerUsername: "chris" }, + { participantName: "Winless Wonder", qpEarned: 0, qpTotal: 0, globalRank: 0, globalRankTied: false, ownerUsername: "sam" }, + ], }); const desc = getDescription(); expect(desc).toContain("**Non-scoring Participants**"); - // Name (seasonTotal, manager) — Rafael has a 20 QP running total, owner "alex" - expect(desc).toContain("• Rafael Nadal (20, alex)"); + // Single comma-separated line: "Name (seasonTotal, manager)", higher total first. + expect(desc).toContain("Also Ran (5, chris), Winless Wonder (0, sam)"); + // Champ is in the top 8, so it must NOT appear in the non-scoring line. + expect(desc).not.toContain("Champ (100"); + // Non-scoring appears below the Top 8 section. + expect(desc.indexOf("Drafted Participants in Top 8")).toBeLessThan( + desc.indexOf("Non-scoring Participants") + ); }); - it("shows Drafted Participants in Top 8 for entries sorted by qpTotal desc", async () => { + it("keeps zero-QP participants tied into the top-8 rank band out of the Top 8 section", async () => { + // Regression: early in a season, standard competition ranking ties every winless + // player into a low rank (here rank 2). A rank-only Top 8 filter would flood the + // section with "T2. Name — 0 QP" rows; the qpTotal>0 guard drops them to Non-scoring. + await sendQualifyingPointsUpdateNotification({ + webhookUrl: WEBHOOK_URL, + seasonName: "Slam League 2025", + entries: [ + { participantName: "Only Scorer", qpEarned: 10, qpTotal: 10, globalRank: 1, globalRankTied: false, ownerUsername: "alex" }, + ], + scoreboard: [ + { participantName: "Only Scorer", qpEarned: 10, qpTotal: 10, globalRank: 1, globalRankTied: false, ownerUsername: "alex" }, + { participantName: "Winless A", qpEarned: 0, qpTotal: 0, globalRank: 2, globalRankTied: true, ownerUsername: "chris" }, + { participantName: "Winless B", qpEarned: 0, qpTotal: 0, globalRank: 2, globalRankTied: true, ownerUsername: "sam" }, + ], + }); + + const desc = getDescription(); + // Top 8 contains only the real scorer. + expect(desc).toContain("1\\. Only Scorer (alex) — 10 QP"); + // The tied 0-QP players must NOT appear as top-8 rows. + expect(desc).not.toContain("T2\\. Winless A"); + expect(desc).not.toContain("T2\\. Winless B"); + // They land in the Non-scoring line instead. + expect(desc).toContain("Winless A (0, chris), Winless B (0, sam)"); + }); + + it("shows Drafted Participants in Top 8 for scoreboard sorted by qpTotal desc", async () => { await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", entries: BASE_ENTRIES, + scoreboard: BASE_ENTRIES, }); const desc = getDescription(); @@ -828,6 +872,11 @@ describe("sendQualifyingPointsUpdateNotification", () => { { participantName: "Player B", qpEarned: 5, qpTotal: 25 }, { participantName: "Player C", qpEarned: 2, qpTotal: 10 }, ]), + scoreboard: withRanks([ + { participantName: "Player A", qpEarned: 10, qpTotal: 25 }, + { participantName: "Player B", qpEarned: 5, qpTotal: 25 }, + { participantName: "Player C", qpEarned: 2, qpTotal: 10 }, + ]), }); const desc = getDescription(); @@ -869,29 +918,31 @@ describe("sendQualifyingPointsUpdateNotification", () => { it("excludes participants ranked outside the top 8 from the Drafted Participants section", async () => { // Only the top 8 (plus ties) of the full season field belong in this section, so a // rank-9 scorer must NOT appear in it — even though it still shows in Points Awarded. + const both = [ + { + participantName: "Player Eight", + qpEarned: 5, + qpTotal: 10, + globalRank: 8, + globalRankTied: false, + ownerUsername: "eighthowner", + }, + { + participantName: "Player Nine", + qpEarned: 3, + qpTotal: 8, + globalRank: 9, + globalRankTied: false, + ownerUsername: "ninthowner", + }, + ]; await sendQualifyingPointsUpdateNotification({ webhookUrl: WEBHOOK_URL, seasonName: "Rumble League 2026", sportName: "Tennis - Men", eventName: "Wimbledon", - entries: [ - { - participantName: "Player Eight", - qpEarned: 5, - qpTotal: 10, - globalRank: 8, - globalRankTied: false, - ownerUsername: "eighthowner", - }, - { - participantName: "Player Nine", - qpEarned: 3, - qpTotal: 8, - globalRank: 9, - globalRankTied: false, - ownerUsername: "ninthowner", - }, - ], + entries: both, + scoreboard: both, }); const desc = getDescription(); @@ -899,7 +950,9 @@ describe("sendQualifyingPointsUpdateNotification", () => { expect(desc).toContain("8\\. Player Eight (eighthowner) — 10 QP"); // The rank-9 player is filtered out of the standings section entirely. expect(desc).not.toContain("9\\. Player Nine"); - // ...but both still earned points, so both remain in Points Awarded. + // ...instead the rank-9 player drops into the Non-scoring line. + expect(desc).toContain("Player Nine (8, ninthowner)"); + // ...and both still earned points, so both remain in Points Awarded. expect(desc).toContain("• **Player Nine (ninthowner)** — 3 QP"); }); @@ -916,6 +969,14 @@ describe("sendQualifyingPointsUpdateNotification", () => { ownerDiscordUserId: "111222333", }, ]), + scoreboard: withRanks([ + { + participantName: "Novak Djokovic", + qpEarned: 20, + qpTotal: 45, + ownerUsername: "alex", + }, + ]), }); const desc = getDescription(); @@ -1021,6 +1082,7 @@ describe("sendQualifyingPointsUpdateNotification", () => { webhookUrl: WEBHOOK_URL, seasonName: "Slam League 2025", entries: longEntries, + scoreboard: longEntries, }); const desc = getDescription(); diff --git a/app/services/__tests__/qualifying-points-discord.server.test.ts b/app/services/__tests__/qualifying-points-discord.server.test.ts index f8ca771..e5e34c1 100644 --- a/app/services/__tests__/qualifying-points-discord.server.test.ts +++ b/app/services/__tests__/qualifying-points-discord.server.test.ts @@ -83,7 +83,7 @@ function makeDb(overrides: Record = {}) { ]), }, seasonParticipants: { - findMany: vi.fn().mockResolvedValue([{ id: PARTICIPANT_ID, name: "Carlos Alcaraz" }]), + findMany: vi.fn().mockResolvedValue([{ id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID }]), }, users: { findMany: vi.fn().mockResolvedValue([ @@ -263,8 +263,8 @@ describe("notifyQualifyingPointsUpdate", () => { }, seasonParticipants: { findMany: vi.fn().mockResolvedValue([ - { id: PARTICIPANT_ID, name: "Carlos Alcaraz" }, - { id: "p-2", name: "Rafael Nadal" }, + { id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID }, + { id: "p-2", name: "Rafael Nadal", sportsSeasonId: SPORTS_SEASON_ID }, ]), }, }); @@ -281,6 +281,84 @@ describe("notifyQualifyingPointsUpdate", () => { expect(call.entries[0].participantName).toBe("Carlos Alcaraz"); }); + it("scoreboard includes every drafted participant even when entries are filtered", async () => { + // The scoreboard powers the Top 8 / Non-scoring sections and must reflect the full + // drafted field, not just this sync's changed participants. Here Nadal (p-2) did not + // change this sync (filtered out of entries) but is drafted, so he belongs on the + // scoreboard with his running total and no QP earned this event. + const db = makeDb({ + eventResults: { + findMany: vi.fn().mockResolvedValue([ + { seasonParticipantId: PARTICIPANT_ID, qualifyingPointsAwarded: "10" }, + { seasonParticipantId: "p-2", qualifyingPointsAwarded: "5" }, + ]), + }, + draftPicks: { + findMany: vi.fn().mockResolvedValue([ + { participantId: PARTICIPANT_ID, seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } }, + { participantId: "p-2", seasonId: SEASON_ID, team: { name: "Beta FC", ownerId: null } }, + ]), + }, + seasonParticipantQualifyingTotals: { + findMany: vi.fn().mockResolvedValue([ + { participantId: PARTICIPANT_ID, totalQualifyingPoints: "45", sportsSeasonId: SPORTS_SEASON_ID }, + { participantId: "p-2", totalQualifyingPoints: "20", sportsSeasonId: SPORTS_SEASON_ID }, + ]), + }, + seasonParticipants: { + findMany: vi.fn().mockResolvedValue([ + { id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID }, + { id: "p-2", name: "Rafael Nadal", sportsSeasonId: SPORTS_SEASON_ID }, + ]), + }, + }); + + await notifyQualifyingPointsUpdate( + SPORTS_SEASON_ID, + SCORING_EVENT_ID, + db as never, + new Set([PARTICIPANT_ID]) + ); + + const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0]; + // entries is scoped to the changed participant… + expect(call.entries).toHaveLength(1); + expect(call.entries[0].participantName).toBe("Carlos Alcaraz"); + // …but the scoreboard carries the whole drafted field. + expect(call.scoreboard).toEqual( + expect.arrayContaining([ + expect.objectContaining({ participantName: "Carlos Alcaraz", qpTotal: 45 }), + expect.objectContaining({ participantName: "Rafael Nadal", qpEarned: 0, qpTotal: 20 }), + ]) + ); + expect(call.scoreboard).toHaveLength(2); + }); + + it("excludes participants from other sports seasons drafted in the same fantasy season", async () => { + // Draft picks span every sport in a fantasy season, so a golf pick can share the + // league with this tennis event. It must not leak into the tennis scoreboard. + const db = makeDb({ + draftPicks: { + findMany: vi.fn().mockResolvedValue([ + { participantId: PARTICIPANT_ID, seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } }, + { participantId: "p-golf", seasonId: SEASON_ID, team: { name: "Alpha FC", ownerId: null } }, + ]), + }, + seasonParticipants: { + findMany: vi.fn().mockResolvedValue([ + { id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID }, + { id: "p-golf", name: "Rory McIlroy", sportsSeasonId: "ss-golf" }, + ]), + }, + }); + + await notifyQualifyingPointsUpdate(SPORTS_SEASON_ID, SCORING_EVENT_ID, db as never); + + const call = vi.mocked(sendQualifyingPointsUpdateNotification).mock.calls[0][0]; + expect(call.scoreboard).toHaveLength(1); + expect(call.scoreboard?.[0]?.participantName).toBe("Carlos Alcaraz"); + }); + it("does not call sendQualifyingPointsUpdateNotification when all participants are filtered out", async () => { const db = makeDb(); @@ -315,8 +393,8 @@ describe("notifyQualifyingPointsUpdate", () => { }, seasonParticipants: { findMany: vi.fn().mockResolvedValue([ - { id: PARTICIPANT_ID, name: "Carlos Alcaraz" }, - { id: MENSIK_ID, name: "Jakob Mensik" }, + { id: PARTICIPANT_ID, name: "Carlos Alcaraz", sportsSeasonId: SPORTS_SEASON_ID }, + { id: MENSIK_ID, name: "Jakob Mensik", sportsSeasonId: SPORTS_SEASON_ID }, ]), }, }); diff --git a/app/services/discord.ts b/app/services/discord.ts index 7db95d4..11b03fb 100644 --- a/app/services/discord.ts +++ b/app/services/discord.ts @@ -288,6 +288,20 @@ export interface QPEliminatedEntry { ownerDiscordUserId?: string; } +/** + * Whether a scoreboard entry belongs in the "Drafted Participants in Top 8" section. + * Requires the participant to have actually scored (qpTotal > 0) AND sit in the top 8 + * of the full season standings. The qpTotal>0 guard matters early in a season: when + * fewer than 8 players have scored, standard competition ranking ties every 0-QP player + * into a rank <= 8 band (e.g. everyone winless is "T5"), and a rank-only filter would + * flood the section with "T5. Name — 0 QP" rows. Rank ties among real scorers at rank 8 + * are still kept ("top 8 plus ties"); globalRank 0 means unranked. The Non-scoring + * section is the exact complement (`!inTopEight`), so the field is partitioned cleanly. + */ +function inTopEight(e: QPEventEntry): boolean { + return e.qpTotal > 0 && e.globalRank >= 1 && e.globalRank <= 8; +} + export async function sendQualifyingPointsUpdateNotification({ webhookUrl, seasonName, @@ -295,6 +309,7 @@ export async function sendQualifyingPointsUpdateNotification({ eventName, entries, eliminated = [], + scoreboard = [], standingsUrl, }: { webhookUrl: string; @@ -303,6 +318,13 @@ export async function sendQualifyingPointsUpdateNotification({ eventName?: string; entries: QPEventEntry[]; eliminated?: QPEliminatedEntry[]; + /** + * The full current scoreboard for the league — every drafted participant, not just + * those whose QP changed this sync. Drives the "Top 8" and "Non-scoring Participants" + * sections. `entries`/`eliminated` remain scoped to this sync's changes and drive the + * "Points Awarded"/"Knocked Out" sections and the ping list. + */ + scoreboard?: QPEventEntry[]; standingsUrl?: string; }): Promise { if (entries.length === 0 && eliminated.length === 0) return; @@ -333,20 +355,6 @@ export async function sendQualifyingPointsUpdateNotification({ } } - const zeroEntries = entries.filter((e) => e.qpEarned === 0); - if (zeroEntries.length > 0) { - sections.push("\n**Non-scoring Participants**"); - for (const e of zeroEntries) { - // Plain manager username here (never a <@id> mention): this section is not - // pinged, so mention chips would misleadingly look like a ping. Show the - // running season QP total alongside the manager, e.g. "Name (1.5, manager)". - const details = e.ownerUsername - ? `${formatQPValue(e.qpTotal)}, ${escapeMarkdown(e.ownerUsername)}` - : `${formatQPValue(e.qpTotal)}`; - sections.push(`• ${escapeMarkdown(e.participantName)} (${details})`); - } - } - // 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) { @@ -364,19 +372,21 @@ export async function sendQualifyingPointsUpdateNotification({ } } - // Only drafted participants sitting in the top 8 of the FULL season standings - // (globalRank supplied by the caller), ordered by rank. Standard competition - // ranking collapses ties to a shared rank, so `globalRank <= 8` naturally keeps - // any tie group sitting at rank 8 ("top 8 plus ties"). - const sorted = [...entries] - .filter((e) => e.globalRank <= 8) + // Scoreboard sections below draw from the FULL drafted field (`scoreboard`), not just + // this sync's movers, so they read as a live standings snapshot. The two sections + // partition the field exactly via `inTopEight`, so nobody is dropped or listed twice. + + // Top 8 = a drafted participant that has actually scored (qpTotal > 0) AND sits in the + // top 8 of the FULL season standings (see `inTopEight`). + const topEight = [...scoreboard] + .filter(inTopEight) .toSorted((a, b) => a.globalRank - b.globalRank); // Skip the section entirely when no drafted participant is in the top 8 (e.g. a // sync that only reported knockouts) so we don't emit an empty header. - if (sorted.length > 0) { + if (topEight.length > 0) { sections.push("\n**Drafted Participants in Top 8**"); - for (const e of sorted) { + for (const e of topEight) { const rankPrefix = e.globalRankTied ? `T${e.globalRank}` : `${e.globalRank}`; // Plain manager username (no <@id> mention): this section is not pinged. const managerLabel = e.ownerUsername ? ` (${escapeMarkdown(e.ownerUsername)})` : ""; @@ -384,6 +394,26 @@ export async function sendQualifyingPointsUpdateNotification({ } } + // Non-scoring section — the rest of the drafted field (everyone not in the top 8, i.e. + // the exact complement of `inTopEight`: rank 9+, unranked, or scored-nothing players + // tied into the top-8 rank band). Rendered as a single compact comma-separated line of + // "Name (points, manager)", highest QP total first. Never pinged, so managers are shown + // by plain username, never as a <@id> mention. + const nonTopEight = [...scoreboard] + .filter((e) => !inTopEight(e)) + .toSorted((a, b) => b.qpTotal - a.qpTotal); + + if (nonTopEight.length > 0) { + sections.push("\n**Non-scoring Participants**"); + const parts = nonTopEight.map((e) => { + const details = e.ownerUsername + ? `${formatQPValue(e.qpTotal)}, ${escapeMarkdown(e.ownerUsername)}` + : `${formatQPValue(e.qpTotal)}`; + return `${escapeMarkdown(e.participantName)} (${details})`; + }); + sections.push(parts.join(", ")); + } + const MAX_DESCRIPTION = 4096; let description = sections.join("\n"); if (description.length > MAX_DESCRIPTION) { diff --git a/app/services/qualifying-points-discord.server.ts b/app/services/qualifying-points-discord.server.ts index 852e3ff..9ed5225 100644 --- a/app/services/qualifying-points-discord.server.ts +++ b/app/services/qualifying-points-discord.server.ts @@ -125,12 +125,22 @@ export async function notifyQualifyingPointsUpdate( }); const seasonMap = new Map(seasons.map((s) => [s.id, s])); - // Batch-fetch participant display names once (same participants across all leagues) - const allParticipantIds = [...new Set([...qpEarnedById.keys(), ...eliminatedIds])]; + // Batch-fetch participant display names once (same participants across all leagues). + // Includes every drafted participant — the scoreboard sections (Top 8 / Non-scoring) + // list the whole drafted field, not just this sync's changed participants. + const allParticipantIds = [ + ...new Set([...qpEarnedById.keys(), ...eliminatedIds, ...draftedParticipantIds]), + ]; const participants = await db.query.seasonParticipants.findMany({ where: inArray(schema.seasonParticipants.id, allParticipantIds), }); const participantNameById = new Map(participants.map((p) => [p.id, p.name])); + // A league's draft picks span every sport in that fantasy season, so the scoreboard + // must be scoped to participants belonging to the sports season being announced — + // otherwise a golf pick would surface in a tennis event's Non-scoring line. + const sportsSeasonParticipantIds = new Set( + participants.filter((p) => p.sportsSeasonId === sportsSeasonId).map((p) => p.id) + ); // Batch-fetch all team owners and their Discord IDs in two queries (not N per league) const allOwnerIds = new Set(); @@ -208,6 +218,25 @@ export async function notifyQualifyingPointsUpdate( }; }); + // Full current scoreboard for this league: every drafted participant, regardless of + // whether their QP changed this sync. Drives the Top 8 and Non-scoring sections so + // they read as a season-standings snapshot rather than only this event's movers. + // Never pinged, so ownerDiscordUserId is intentionally omitted. + const scoreboard: QPEventEntry[] = [...teamByParticipantId.keys()] + .filter((participantId) => sportsSeasonParticipantIds.has(participantId)) + .map((participantId) => { + const ownerId = teamByParticipantId.get(participantId)?.ownerId ?? null; + const globalRank = globalRankById.get(participantId) ?? 0; + return { + participantName: participantNameById.get(participantId) ?? participantId, + qpEarned: qpEarnedById.get(participantId) ?? 0, + qpTotal: qpTotalById.get(participantId) ?? 0, + globalRank, + globalRankTied: (countByRank.get(globalRank) ?? 0) > 1, + ownerUsername: ownerId ? (usernameByUserId.get(ownerId) ?? undefined) : undefined, + }; + }); + await sendQualifyingPointsUpdateNotification({ webhookUrl, seasonName, @@ -215,6 +244,7 @@ export async function notifyQualifyingPointsUpdate( eventName, entries, eliminated, + scoreboard, standingsUrl, }); }