mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-09 04:02:40 -05:00
437 lines
12 KiB
TypeScript
437 lines
12 KiB
TypeScript
import cachified from "@epic-web/cachified";
|
|
import * as R from "remeda";
|
|
import type { ShowcaseCalendarEvent } from "~/features/calendar/calendar-types";
|
|
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
|
import {
|
|
getBracketProgressionLabel,
|
|
tournamentIsRanked,
|
|
} from "~/features/tournament/tournament-utils";
|
|
import * as Progression from "~/features/tournament-bracket/core/Progression";
|
|
import { getTentativeTier } from "~/features/tournament-organization/core/tentativeTiers.server";
|
|
import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server";
|
|
import {
|
|
databaseTimestampToDate,
|
|
dateToDatabaseTimestamp,
|
|
} from "~/utils/dates";
|
|
import type { CommonUser } from "~/utils/kysely.server";
|
|
import { tournamentPage } from "~/utils/urls";
|
|
|
|
interface ShowcaseTournamentCollection {
|
|
participatingFor: ShowcaseCalendarEvent[];
|
|
organizingFor: ShowcaseCalendarEvent[];
|
|
showcase: ShowcaseCalendarEvent[];
|
|
results: ShowcaseCalendarEvent[];
|
|
}
|
|
|
|
interface ParticipationInfo {
|
|
participants: Set<ShowcaseCalendarEvent["id"]>;
|
|
organizers: Set<ShowcaseCalendarEvent["id"]>;
|
|
}
|
|
|
|
export async function upcomingTournaments(): Promise<ShowcaseCalendarEvent[]> {
|
|
const tournaments = await cachedTournaments();
|
|
return tournaments.upcoming;
|
|
}
|
|
|
|
export async function categorizedTournamentsByUserId(
|
|
userId: number | null,
|
|
): Promise<ShowcaseTournamentCollection> {
|
|
const tournaments = await cachedTournaments();
|
|
const participation = await cachedParticipationInfo(
|
|
userId,
|
|
tournaments.upcoming,
|
|
);
|
|
|
|
return {
|
|
organizingFor: tournaments.upcoming.filter((tournament) =>
|
|
participation.organizers.has(tournament.id),
|
|
),
|
|
participatingFor: tournaments.upcoming.filter(
|
|
(tournament) =>
|
|
!tournament.hidden && participation.participants.has(tournament.id),
|
|
),
|
|
showcase: resolveShowcaseTournaments(
|
|
tournaments.upcoming.filter(
|
|
(tournament) =>
|
|
!tournament.hidden &&
|
|
!participation.organizers.has(tournament.id) &&
|
|
!participation.participants.has(tournament.id),
|
|
),
|
|
),
|
|
results: tournaments.results,
|
|
};
|
|
}
|
|
|
|
let participationInfoMap: Map<CommonUser["id"], ParticipationInfo> | null =
|
|
null;
|
|
|
|
const emptyParticipationInfo = (): ParticipationInfo => ({
|
|
participants: new Set(),
|
|
organizers: new Set(),
|
|
});
|
|
|
|
export function clearParticipationInfoMap() {
|
|
participationInfoMap = null;
|
|
}
|
|
|
|
export function addToCached({
|
|
userId,
|
|
tournamentId,
|
|
type,
|
|
newTeamCount,
|
|
}: {
|
|
userId: number;
|
|
tournamentId: number;
|
|
type: "participant" | "organizer";
|
|
/** If a new team joined, the new total team count for the tournament including the new one */
|
|
newTeamCount?: number;
|
|
}) {
|
|
if (!participationInfoMap) return;
|
|
|
|
const participation =
|
|
participationInfoMap.get(userId) ?? emptyParticipationInfo();
|
|
|
|
if (type === "participant") {
|
|
participation.participants.add(tournamentId);
|
|
} else if (type === "organizer") {
|
|
participation.organizers.add(tournamentId);
|
|
}
|
|
|
|
participationInfoMap.set(userId, participation);
|
|
|
|
if (typeof newTeamCount === "number") {
|
|
updateCachedTournamentTeamCount({
|
|
tournamentId,
|
|
newTeamCount,
|
|
});
|
|
}
|
|
}
|
|
|
|
export function removeFromCached({
|
|
userId,
|
|
tournamentId,
|
|
type,
|
|
}: {
|
|
userId: number;
|
|
tournamentId: number;
|
|
type: "participant" | "organizer";
|
|
}) {
|
|
if (!participationInfoMap) return;
|
|
|
|
const participation = participationInfoMap.get(userId);
|
|
if (!participation) return;
|
|
|
|
if (type === "participant") {
|
|
participation.participants.delete(tournamentId);
|
|
} else if (type === "organizer") {
|
|
participation.organizers.delete(tournamentId);
|
|
}
|
|
|
|
participationInfoMap.set(userId, participation);
|
|
}
|
|
|
|
export function updateCachedTournamentTeamCount({
|
|
tournamentId,
|
|
newTeamCount,
|
|
}: {
|
|
tournamentId: number;
|
|
newTeamCount: number;
|
|
}) {
|
|
cachedTournaments().then((tournaments) => {
|
|
const tournament = tournaments.upcoming.find(
|
|
(tournament) => tournament.id === tournamentId,
|
|
);
|
|
if (tournament) {
|
|
tournament.teamsCount = newTeamCount;
|
|
}
|
|
});
|
|
}
|
|
|
|
async function cachedParticipationInfo(
|
|
userId: number | null,
|
|
tournaments: ShowcaseCalendarEvent[],
|
|
): Promise<ParticipationInfo> {
|
|
if (!userId) {
|
|
return emptyParticipationInfo();
|
|
}
|
|
|
|
if (participationInfoMap) {
|
|
return participationInfoMap.get(userId) ?? emptyParticipationInfo();
|
|
}
|
|
|
|
const participation = await tournamentsToParticipationInfoMap(tournaments);
|
|
participationInfoMap = participation;
|
|
|
|
return participation.get(userId) ?? emptyParticipationInfo();
|
|
}
|
|
|
|
const SHOWCASE_TOURNAMENTS_CACHE_KEY = "front-tournaments-list";
|
|
|
|
export const clearCachedTournaments = () =>
|
|
cache.delete(SHOWCASE_TOURNAMENTS_CACHE_KEY);
|
|
|
|
async function cachedTournaments() {
|
|
return cachified({
|
|
key: SHOWCASE_TOURNAMENTS_CACHE_KEY,
|
|
cache,
|
|
ttl: ttl(IN_MILLISECONDS.TWO_HOURS),
|
|
async getFreshValue() {
|
|
const tournaments = await TournamentRepository.forShowcase();
|
|
const mapped = tournaments.map(mapTournamentFromDB);
|
|
|
|
return deleteExtraResults(mapped);
|
|
},
|
|
});
|
|
}
|
|
|
|
function deleteExtraResults(tournaments: ShowcaseCalendarEvent[]) {
|
|
const threeDaysAgo = databaseTimestampThreeDaysAgo();
|
|
const nonResults = tournaments.filter(
|
|
(tournament) =>
|
|
tournament.firstPlacers.length === 0 &&
|
|
!tournament.isFinalized &&
|
|
tournament.startTime > threeDaysAgo,
|
|
);
|
|
|
|
const rankedResults = tournaments
|
|
.filter(
|
|
(tournament) => tournament.firstPlacers.length > 0 && tournament.isRanked,
|
|
)
|
|
.sort((a, b) => showcaseScore(b) - showcaseScore(a));
|
|
const nonRankedResults = tournaments
|
|
.filter(
|
|
(tournament) =>
|
|
tournament.firstPlacers.length > 0 && !tournament.isRanked,
|
|
)
|
|
.sort((a, b) => showcaseScore(b) - showcaseScore(a));
|
|
|
|
const rankedResultsToKeep = rankedResults.slice(0, 4);
|
|
// min 2, max 6 non ranked results
|
|
const nonRankedResultsToKeep = nonRankedResults.slice(
|
|
0,
|
|
6 - rankedResultsToKeep.length,
|
|
);
|
|
|
|
return {
|
|
results: [...rankedResultsToKeep, ...nonRankedResultsToKeep].sort(
|
|
(a, b) => b.startTime - a.startTime,
|
|
),
|
|
upcoming: nonResults,
|
|
};
|
|
}
|
|
|
|
function resolveShowcaseTournaments(
|
|
tournaments: ShowcaseCalendarEvent[],
|
|
): ShowcaseCalendarEvent[] {
|
|
const happeningDuringNextWeek = tournaments.filter(
|
|
(tournament) =>
|
|
tournament.startTime > databaseTimestampSixHoursAgo() &&
|
|
tournament.startTime < databaseTimestampWeekFromNow(),
|
|
);
|
|
const sorted = happeningDuringNextWeek.sort(
|
|
(a, b) => b.teamsCount - a.teamsCount,
|
|
);
|
|
|
|
const ranked = sorted.filter((tournament) => tournament.isRanked).slice(0, 3);
|
|
// min 3, max 6 non ranked
|
|
const nonRanked = sorted
|
|
.filter((tournament) => !tournament.isRanked)
|
|
.slice(0, 6 - ranked.length);
|
|
|
|
return [...ranked, ...nonRanked].sort((a, b) => a.startTime - b.startTime);
|
|
}
|
|
|
|
async function tournamentsToParticipationInfoMap(
|
|
tournaments: ShowcaseCalendarEvent[],
|
|
): Promise<Map<CommonUser["id"], ParticipationInfo>> {
|
|
const tournamentIds = tournaments.map((tournament) => tournament.id);
|
|
const tournamentsWithUsers =
|
|
await TournamentRepository.relatedUsersByTournamentIds(tournamentIds);
|
|
|
|
const result: Map<CommonUser["id"], ParticipationInfo> = new Map();
|
|
|
|
const addToMap = (
|
|
userId: number,
|
|
tournamentId: number,
|
|
type: "participant" | "organizer",
|
|
) => {
|
|
const participation = result.get(userId) ?? emptyParticipationInfo();
|
|
|
|
if (type === "participant") {
|
|
participation.participants.add(tournamentId);
|
|
} else if (type === "organizer") {
|
|
participation.organizers.add(tournamentId);
|
|
}
|
|
|
|
result.set(userId, participation);
|
|
};
|
|
|
|
for (const tournament of tournamentsWithUsers) {
|
|
for (const { userId } of tournament.teamMembers) {
|
|
addToMap(userId, tournament.id, "participant");
|
|
}
|
|
|
|
for (const { userId } of tournament.staff) {
|
|
addToMap(userId, tournament.id, "organizer");
|
|
}
|
|
|
|
for (const { userId } of tournament.organizationMembers) {
|
|
addToMap(userId, tournament.id, "organizer");
|
|
}
|
|
|
|
addToMap(tournament.authorId, tournament.id, "organizer");
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
const MEMBERS_TO_SHOW = 5;
|
|
|
|
function mapTournamentFromDB(
|
|
tournament: TournamentRepository.ForShowcase,
|
|
): ShowcaseCalendarEvent {
|
|
const firstPlacers = resolveFirstPlacers(tournament);
|
|
|
|
const tentativeTier =
|
|
tournament.tier === null &&
|
|
tournament.organizationId !== null &&
|
|
!tournament.firstPlacers.length
|
|
? getTentativeTier(tournament.organizationId, tournament.name)
|
|
: null;
|
|
|
|
return {
|
|
type: "showcase",
|
|
url: tournamentPage(tournament.id),
|
|
id: tournament.id,
|
|
authorId: tournament.authorId,
|
|
name: tournament.name,
|
|
startTime: tournament.startTime,
|
|
teamsCount: tournament.teamsCount,
|
|
logoUrl: tournament.logoUrl,
|
|
organization: tournament.organization
|
|
? {
|
|
name: tournament.organization.name,
|
|
slug: tournament.organization.slug,
|
|
}
|
|
: null,
|
|
isRanked: tournamentIsRanked({
|
|
isSetAsRanked: tournament.settings.isRanked,
|
|
startTime: databaseTimestampToDate(tournament.startTime),
|
|
minMembersPerTeam: tournament.settings.minMembersPerTeam ?? 4,
|
|
isTest: tournament.settings.isTest ?? false,
|
|
}),
|
|
tier: tournament.tier ?? null,
|
|
tentativeTier,
|
|
hidden: Boolean(tournament.hidden),
|
|
isFinalized: Boolean(tournament.isFinalized),
|
|
minMembersPerTeam: tournament.settings.minMembersPerTeam ?? 4,
|
|
modes: null,
|
|
hasVods: (tournament.vodCount ?? 0) > 0,
|
|
firstPlacers,
|
|
};
|
|
}
|
|
|
|
type FirstPlacerRow = TournamentRepository.ForShowcase["firstPlacers"][number];
|
|
|
|
function resolveFirstPlacers(
|
|
tournament: TournamentRepository.ForShowcase,
|
|
): ShowcaseCalendarEvent["firstPlacers"] {
|
|
if (tournament.firstPlacers.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
if (
|
|
Progression.hasAbDivisionsFinals(tournament.settings.bracketProgression)
|
|
) {
|
|
const byDiv = R.groupBy(tournament.firstPlacers, (p) => p.div ?? "");
|
|
return Object.values(byDiv)
|
|
.map((rows) => buildFirstPlacerEntry(rows, { withMembers: false }))
|
|
.sort((a, b) => (a.div ?? "").localeCompare(b.div ?? ""));
|
|
}
|
|
|
|
const winnerRows = winnersOfHighestDivision(tournament);
|
|
return [buildFirstPlacerEntry(winnerRows, { withMembers: true })];
|
|
}
|
|
|
|
function winnersOfHighestDivision(
|
|
tournament: TournamentRepository.ForShowcase,
|
|
): FirstPlacerRow[] {
|
|
if (tournament.firstPlacers.every((p) => p.div === null)) {
|
|
return tournament.firstPlacers;
|
|
}
|
|
|
|
const highestDivName = getBracketProgressionLabel(
|
|
0,
|
|
tournament.settings.bracketProgression,
|
|
);
|
|
const highestDivWinners = tournament.firstPlacers.filter(
|
|
(p) => p.div === highestDivName,
|
|
);
|
|
|
|
return highestDivWinners.length > 0
|
|
? highestDivWinners
|
|
: tournament.firstPlacers;
|
|
}
|
|
|
|
function buildFirstPlacerEntry(
|
|
rows: FirstPlacerRow[],
|
|
{ withMembers }: { withMembers: boolean },
|
|
): ShowcaseCalendarEvent["firstPlacers"][number] {
|
|
const first = rows[0];
|
|
const members = withMembers
|
|
? rows.slice(0, MEMBERS_TO_SHOW).map((row) => ({
|
|
customUrl: row.customUrl,
|
|
discordAvatar: row.discordAvatar,
|
|
discordId: row.discordId,
|
|
id: row.id,
|
|
username: row.username,
|
|
country: row.country,
|
|
}))
|
|
: [];
|
|
|
|
return {
|
|
teamName: first.teamName,
|
|
logoUrl: first.teamLogoUrl ?? first.pickupAvatarUrl,
|
|
div: first.div,
|
|
members,
|
|
notShownMembersCount:
|
|
withMembers && rows.length > MEMBERS_TO_SHOW
|
|
? rows.length - MEMBERS_TO_SHOW
|
|
: 0,
|
|
};
|
|
}
|
|
|
|
function databaseTimestampWeekFromNow() {
|
|
const now = new Date();
|
|
|
|
now.setDate(now.getDate() + 7);
|
|
|
|
return dateToDatabaseTimestamp(now);
|
|
}
|
|
|
|
function databaseTimestampThreeDaysAgo() {
|
|
const now = new Date();
|
|
|
|
now.setDate(now.getDate() - 3);
|
|
|
|
return dateToDatabaseTimestamp(now);
|
|
}
|
|
|
|
function databaseTimestampSixHoursAgo() {
|
|
const now = new Date();
|
|
|
|
now.setHours(now.getHours() - 6);
|
|
|
|
return dateToDatabaseTimestamp(now);
|
|
}
|
|
|
|
const TIER_BONUS_PER_STEP = 5;
|
|
function showcaseScore(tournament: ShowcaseCalendarEvent): number {
|
|
const tierBonus =
|
|
typeof tournament.tier === "number"
|
|
? (10 - tournament.tier) * TIER_BONUS_PER_STEP
|
|
: 0;
|
|
|
|
return tournament.teamsCount + tierBonus;
|
|
}
|