elimination announcements (#114)
This commit is contained in:
parent
b8c21d227b
commit
e3e8485e26
4 changed files with 201 additions and 41 deletions
|
|
@ -9,7 +9,7 @@ import {
|
||||||
} from "./scoring-rules";
|
} from "./scoring-rules";
|
||||||
import { getSeasonResults } from "./participant-season-result";
|
import { getSeasonResults } from "./participant-season-result";
|
||||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
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 { BRACKET_TEMPLATES, type BracketRound } from "~/lib/bracket-templates";
|
||||||
import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff-match";
|
import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff-match";
|
||||||
import { getUserDisplayName } from "~/models/user";
|
import { getUserDisplayName } from "~/models/user";
|
||||||
|
|
@ -1657,7 +1657,7 @@ export async function recalculateStandings(
|
||||||
export async function recalculateAffectedLeagues(
|
export async function recalculateAffectedLeagues(
|
||||||
sportsSeasonId: string,
|
sportsSeasonId: string,
|
||||||
providedDb?: ReturnType<typeof database>,
|
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> {
|
): Promise<void> {
|
||||||
const db = providedDb || database();
|
const db = providedDb || database();
|
||||||
|
|
||||||
|
|
@ -1726,6 +1726,18 @@ export async function recalculateAffectedLeagues(
|
||||||
for (const r of loserResults) finalizedLoserIds.add(r.participantId);
|
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
|
// Recalculate each affected season
|
||||||
for (const seasonId of seasonIds) {
|
for (const seasonId of seasonIds) {
|
||||||
// Capture standings before recalculation for delta calculation
|
// Capture standings before recalculation for delta calculation
|
||||||
|
|
@ -1800,6 +1812,19 @@ export async function recalculateAffectedLeagues(
|
||||||
afterStandings.map((s) => [s.teamId, s.team.ownerId])
|
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.
|
// Build scored matches for the notification.
|
||||||
// Winners only appear if their team's score changed (they earned points this round).
|
// 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
|
// Losers appear if their team's score changed OR they were definitively eliminated
|
||||||
|
|
@ -1809,11 +1834,6 @@ export async function recalculateAffectedLeagues(
|
||||||
// correctly suppressed.
|
// correctly suppressed.
|
||||||
let scoredMatches: ScoredMatch[] | undefined;
|
let scoredMatches: ScoredMatch[] | undefined;
|
||||||
if (allCompletedMatches.length > 0) {
|
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(
|
const relevant = allCompletedMatches.filter(
|
||||||
(m) =>
|
(m) =>
|
||||||
(m.winnerId && draftedIds.has(m.winnerId)) ||
|
(m.winnerId && draftedIds.has(m.winnerId)) ||
|
||||||
|
|
@ -1821,10 +1841,6 @@ export async function recalculateAffectedLeagues(
|
||||||
);
|
);
|
||||||
|
|
||||||
if (relevant.length > 0) {
|
if (relevant.length > 0) {
|
||||||
const teamIdByParticipantId = new Map(
|
|
||||||
draftPicks.map((p) => [p.participantId, p.teamId])
|
|
||||||
);
|
|
||||||
|
|
||||||
const lookupOwner = (participantId: string | null | undefined) => {
|
const lookupOwner = (participantId: string | null | undefined) => {
|
||||||
if (!participantId) return undefined;
|
if (!participantId) return undefined;
|
||||||
const teamId = teamIdByParticipantId.get(participantId);
|
const teamId = teamIdByParticipantId.get(participantId);
|
||||||
|
|
@ -1853,11 +1869,32 @@ 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 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(
|
const hasScoredMatchesToShow = scoredMatches?.some(
|
||||||
(m) => m.winnerUsername !== undefined || m.loserUsername !== undefined
|
(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) => ({
|
const standings = afterStandings.map((s) => ({
|
||||||
teamId: s.teamId,
|
teamId: s.teamId,
|
||||||
|
|
@ -1878,6 +1915,7 @@ export async function recalculateAffectedLeagues(
|
||||||
sportName,
|
sportName,
|
||||||
eventName: options?.eventName,
|
eventName: options?.eventName,
|
||||||
scoredMatches,
|
scoredMatches,
|
||||||
|
eliminatedTeams,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Log but don't fail the scoring pipeline if Discord is unreachable
|
// Log but don't fail the scoring pipeline if Discord is unreachable
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,45 @@ async function scoreQualifyingBracket(
|
||||||
await fanOutMajorIfPrimary(event, { markComplete: false });
|
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<number> {
|
||||||
|
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) {
|
export async function action({ request, params }: Route.ActionArgs) {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const intent = formData.get("intent");
|
const intent = formData.get("intent");
|
||||||
|
|
@ -294,27 +333,16 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
try {
|
try {
|
||||||
await generateBracketFromTemplate(params.eventId, templateId, participantIds, regionOverride);
|
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);
|
const event = await getScoringEventById(params.eventId);
|
||||||
if (event) {
|
if (event) {
|
||||||
// Get all participants in the sports season
|
|
||||||
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
||||||
|
|
||||||
// Create a set of participants in the bracket for fast lookup
|
|
||||||
const participantsInBracket = new Set(participantIds);
|
const participantsInBracket = new Set(participantIds);
|
||||||
|
const toEliminate = allParticipants
|
||||||
// Mark participants NOT in the bracket as eliminated
|
.filter((p) => !participantsInBracket.has(p.id))
|
||||||
for (const participant of allParticipants) {
|
.map((p) => p.id);
|
||||||
if (!participantsInBracket.has(participant.id)) {
|
const eliminatedCount = await markEliminatedAndAnnounce(event, toEliminate);
|
||||||
await setParticipantResult(
|
logger.log(`[BracketGeneration] Marked ${eliminatedCount} participants as eliminated`);
|
||||||
participant.id,
|
|
||||||
event.sportsSeasonId,
|
|
||||||
0 // 0 = didn't make playoffs, eliminated
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.log(`[BracketGeneration] Marked ${allParticipants.length - participantIds.length} participants as eliminated`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the event to store the template ID, scoring start round, and region config
|
// Update the event to store the template ID, scoring start round, and region config
|
||||||
|
|
@ -985,14 +1013,16 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
await generateBracketFromTemplate(params.eventId, templateId);
|
await generateBracketFromTemplate(params.eventId, templateId);
|
||||||
|
|
||||||
// Eliminate participants from this sport season who are not in any group
|
// Eliminate participants from this sport season who are not in any group
|
||||||
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
// (and announce to leagues for fantasy events).
|
||||||
let eliminatedCount = 0;
|
const groupsEvent = await getScoringEventById(params.eventId);
|
||||||
for (const participant of allParticipants) {
|
if (!groupsEvent) {
|
||||||
if (!uniqueParticipants.has(participant.id)) {
|
return { error: "Event not found" };
|
||||||
await setParticipantResult(participant.id, params.id, 0);
|
|
||||||
eliminatedCount++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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 {
|
return {
|
||||||
success: `Groups and knockout bracket structure created successfully${eliminatedCount > 0 ? ` (${eliminatedCount} participant(s) not in any group marked as eliminated)` : ""}`,
|
success: `Groups and knockout bracket structure created successfully${eliminatedCount > 0 ? ` (${eliminatedCount} participant(s) not in any group marked as eliminated)` : ""}`,
|
||||||
|
|
@ -1117,11 +1147,10 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
// Assign participants to knockout bracket
|
// Assign participants to knockout bracket
|
||||||
await assignParticipantsToKnockout(params.eventId, assignments);
|
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 eliminatedIds = await getEliminatedParticipantIds(params.eventId);
|
||||||
for (const participantId of eliminatedIds) {
|
await markEliminatedAndAnnounce(event, eliminatedIds);
|
||||||
await setParticipantResult(participantId, event.sportsSeasonId, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.log(
|
logger.log(
|
||||||
`[PopulateKnockout] Assigned 32 participants to knockout, marked ${eliminatedIds.length} as eliminated`
|
`[PopulateKnockout] Assigned 32 participants to knockout, marked ${eliminatedIds.length} as eliminated`
|
||||||
|
|
|
||||||
|
|
@ -441,6 +441,70 @@ describe("sendStandingsUpdateNotification", () => {
|
||||||
expect(desc).not.toContain("Alpha");
|
expect(desc).not.toContain("Alpha");
|
||||||
expect(desc).not.toContain("Beta");
|
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", () => {
|
describe("sendDraftOrderNotification", () => {
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,12 @@ export interface ScoredMatch {
|
||||||
loserDiscordUserId?: string;
|
loserDiscordUserId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EliminatedTeam {
|
||||||
|
participantName: string;
|
||||||
|
username?: string;
|
||||||
|
discordUserId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendStandingsUpdateNotification({
|
export async function sendStandingsUpdateNotification({
|
||||||
webhookUrl,
|
webhookUrl,
|
||||||
seasonName,
|
seasonName,
|
||||||
|
|
@ -90,6 +96,7 @@ export async function sendStandingsUpdateNotification({
|
||||||
sportName,
|
sportName,
|
||||||
eventName,
|
eventName,
|
||||||
scoredMatches,
|
scoredMatches,
|
||||||
|
eliminatedTeams,
|
||||||
}: {
|
}: {
|
||||||
webhookUrl: string;
|
webhookUrl: string;
|
||||||
seasonName: string;
|
seasonName: string;
|
||||||
|
|
@ -99,6 +106,7 @@ export async function sendStandingsUpdateNotification({
|
||||||
sportName?: string;
|
sportName?: string;
|
||||||
eventName?: string;
|
eventName?: string;
|
||||||
scoredMatches?: ScoredMatch[];
|
scoredMatches?: ScoredMatch[];
|
||||||
|
eliminatedTeams?: EliminatedTeam[];
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const sections: string[] = [];
|
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 isTied = buildTiedRankChecker(standings.map((s) => s.rank));
|
||||||
const rankLabel = (rank: number) => (isTied(rank) ? `T${rank}` : `${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.winnerDiscordUserId) pingUserIds.add(m.winnerDiscordUserId);
|
||||||
if (m.loserDiscordUserId) pingUserIds.add(m.loserDiscordUserId);
|
if (m.loserDiscordUserId) pingUserIds.add(m.loserDiscordUserId);
|
||||||
}
|
}
|
||||||
|
for (const t of eliminatedTeams ?? []) {
|
||||||
|
if (t.discordUserId) pingUserIds.add(t.discordUserId);
|
||||||
|
}
|
||||||
const pingIds = [...pingUserIds];
|
const pingIds = [...pingUserIds];
|
||||||
|
|
||||||
const payload: DiscordWebhookPayload = {
|
const payload: DiscordWebhookPayload = {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue