diff --git a/app/features/api-public/routes/tournament.$id.teams.ts b/app/features/api-public/routes/tournament.$id.teams.ts index 6af09e6fc..db6d18577 100644 --- a/app/features/api-public/routes/tournament.$id.teams.ts +++ b/app/features/api-public/routes/tournament.$id.teams.ts @@ -3,7 +3,10 @@ import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite"; import { cors } from "remix-utils/cors"; import { z } from "zod"; import { db } from "~/db/sql"; +import { ordinalToSp } from "~/features/mmr/mmr-utils"; +import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import i18next from "~/modules/i18n/i18next.server"; +import { nullifyingAvg } from "~/utils/arrays"; import { databaseTimestampToDate } from "~/utils/dates"; import { parseParams } from "~/utils/remix.server"; import { userSubmittedImage } from "~/utils/urls"; @@ -66,6 +69,16 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { eb .selectFrom("TournamentTeamMember") .innerJoin("User", "User.id", "TournamentTeamMember.userId") + .leftJoin("SeedingSkill as RankedSeedingSkill", (join) => + join + .onRef("User.id", "=", "RankedSeedingSkill.userId") + .on("RankedSeedingSkill.type", "=", "RANKED"), + ) + .leftJoin("SeedingSkill as UnrankedSeedingSkill", (join) => + join + .onRef("User.id", "=", "UnrankedSeedingSkill.userId") + .on("UnrankedSeedingSkill.type", "=", "UNRANKED"), + ) .select([ "User.id as userId", "User.username", @@ -75,6 +88,8 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { "TournamentTeamMember.inGameName", "TournamentTeamMember.isOwner", "TournamentTeamMember.createdAt", + "RankedSeedingSkill.ordinal as rankedOrdinal", + "UnrankedSeedingSkill.ordinal as unrankedOrdinal", ]) .whereRef( "TournamentTeamMember.tournamentTeamId", @@ -94,6 +109,8 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { .orderBy("TournamentTeam.createdAt asc") .execute(); + const friendCodes = await TournamentRepository.friendCodesByTournamentId(id); + const logoUrl = (team: (typeof teams)[number]) => { const url = team.team?.logoUrl ?? team.avatarUrl; if (!url) return null; @@ -113,6 +130,14 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { seed: team.seed, registeredAt: databaseTimestampToDate(team.createdAt).toISOString(), checkedIn: Boolean(team.checkedInAt), + seedingPower: { + ranked: toSeedingPowerSP( + team.members.map((member) => member.rankedOrdinal), + ), + unranked: toSeedingPowerSP( + team.members.map((member) => member.unrankedOrdinal), + ), + }, members: team.members.map((member) => { return { userId: member.userId, @@ -124,6 +149,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { : null, captain: Boolean(member.isOwner), inGameName: member.inGameName, + friendCode: friendCodes[member.userId], joinedAt: databaseTimestampToDate(member.createdAt).toISOString(), }; }), @@ -145,3 +171,13 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { return await cors(request, json(result)); }; + +function toSeedingPowerSP(ordinals: (number | null)[]) { + const avg = nullifyingAvg( + ordinals.filter((ordinal) => typeof ordinal === "number"), + ); + + if (typeof avg !== "number") return null; + + return ordinalToSp(avg); +} diff --git a/app/features/api-public/schema.ts b/app/features/api-public/schema.ts index 5145a8167..18be5f01e 100644 --- a/app/features/api-public/schema.ts +++ b/app/features/api-public/schema.ts @@ -115,6 +115,15 @@ export type GetTournamentTeamsResponse = Array<{ logoUrl: string | null; seed: number | null; mapPool: Array | null; + /** + * Seeding power is a non-resetting MMR value that is used for sendou.ink's autoseeding capabilities. + * It is calculated as the average of the team's members' seeding power. + * Ranked and unranked tournaments contribute to different seeding power values. + */ + seedingPower: { + ranked: number | null; + unranked: number | null; + }; members: Array<{ userId: number; /** @@ -141,6 +150,12 @@ export type GetTournamentTeamsResponse = Array<{ * @example "Sendou#2955" */ inGameName: string | null; + /** + * Switch friend code used for identification purposes. + * + * @example "1234-5678-9101" + */ + friendCode: string; /** * @example "2024-01-12T20:00:00.000Z" */ diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index 5b46bc0a2..ada4ee63a 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -12,7 +12,7 @@ import type { import * as Progression from "~/features/tournament-bracket/core/Progression"; import { Status } from "~/modules/brackets-model"; import { modesShort } from "~/modules/in-game-lists"; -import { nullFilledArray } from "~/utils/arrays"; +import { nullFilledArray, nullifyingAvg } from "~/utils/arrays"; import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates"; import { COMMON_USER_FIELDS, userChatNameColor } from "~/utils/kysely.server"; import type { Unwrapped } from "~/utils/types"; @@ -317,11 +317,6 @@ export async function findById(id: number) { }; } -function nullifyingAvg(values: number[]) { - if (values.length === 0) return null; - return values.reduce((acc, cur) => acc + cur, 0) / values.length; -} - export async function findChildTournaments(parentTournamentId: number) { const rows = await db .selectFrom("Tournament") diff --git a/app/utils/arrays.ts b/app/utils/arrays.ts index 94296032d..742f36ade 100644 --- a/app/utils/arrays.ts +++ b/app/utils/arrays.ts @@ -97,3 +97,14 @@ export function pickRandomItem(array: T[]): T { export function filterOutFalsy(arr: (T | null | undefined)[]): T[] { return arr.filter(Boolean) as T[]; } + +/** + * Calculates the average of an array of numbers. If the array is empty, returns null. + * + * @param values - An array of numbers to calculate the average of. + * @returns The average of the numbers in the array, or null if the array is empty. + */ +export function nullifyingAvg(values: number[]) { + if (values.length === 0) return null; + return values.reduce((acc, cur) => acc + cur, 0) / values.length; +}