diff --git a/app/components/TierPill.module.css b/app/components/TierPill.module.css new file mode 100644 index 000000000..68e06dab4 --- /dev/null +++ b/app/components/TierPill.module.css @@ -0,0 +1,55 @@ +.pill { + font-size: var(--fonts-xxs); + font-weight: var(--bold); + border-radius: var(--rounded-sm); + padding: var(--s-0-5) var(--s-1-5); + display: grid; + place-items: center; + min-width: 24px; + text-align: center; +} + +.tierX { + background-color: hsl(45, 100%, 30%); + color: hsl(45, 100%, 90%); +} + +.tierSPlus { + background-color: hsl(280, 60%, 35%); + color: hsl(280, 80%, 90%); +} + +.tierS { + background-color: hsl(280, 50%, 40%); + color: hsl(280, 70%, 92%); +} + +.tierAPlus { + background-color: hsl(210, 60%, 35%); + color: hsl(210, 80%, 90%); +} + +.tierA { + background-color: hsl(210, 50%, 40%); + color: hsl(210, 70%, 92%); +} + +.tierBPlus { + background-color: hsl(140, 45%, 32%); + color: hsl(140, 60%, 90%); +} + +.tierB { + background-color: hsl(140, 35%, 38%); + color: hsl(140, 50%, 92%); +} + +.tierCPlus { + background-color: hsl(0, 0%, 35%); + color: hsl(0, 0%, 90%); +} + +.tierC { + background-color: hsl(0, 0%, 40%); + color: hsl(0, 0%, 92%); +} diff --git a/app/components/TierPill.tsx b/app/components/TierPill.tsx new file mode 100644 index 000000000..174493a45 --- /dev/null +++ b/app/components/TierPill.tsx @@ -0,0 +1,43 @@ +import clsx from "clsx"; +import { useTranslation } from "react-i18next"; +import { tierNumberToName } from "~/features/tournament/core/tiering"; +import styles from "./TierPill.module.css"; + +const TIER_STYLE_CLASS: Record = { + 1: styles.tierX, + 2: styles.tierSPlus, + 3: styles.tierS, + 4: styles.tierAPlus, + 5: styles.tierA, + 6: styles.tierBPlus, + 7: styles.tierB, + 8: styles.tierCPlus, + 9: styles.tierC, +}; + +export function TierPill({ + tier, + isTentative = false, +}: { + tier: number; + isTentative?: boolean; +}) { + const { t } = useTranslation(); + const tierName = tierNumberToName(tier); + const tierClass = TIER_STYLE_CLASS[tier] ?? ""; + const displayName = isTentative ? `~${tierName}` : tierName; + + return ( +
+ {displayName} +
+ ); +} diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 475649f44..4b7a4f808 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -2748,9 +2748,23 @@ async function organization() { roleDisplayName: null, }, ], - series: [], + series: [ + { + name: "PICNIC", + description: "PICNIC tournament series", + showLeaderboard: false, + }, + ], badges: [], }); + + sql + .prepare( + `UPDATE "TournamentOrganizationSeries" + SET "tierHistory" = '[3, 4, 3]' + WHERE "organizationId" = 1 AND "name" = 'PICNIC'`, + ) + .run(); } function liveStreams() { diff --git a/app/db/tables.ts b/app/db/tables.ts index 7a88254a5..34b86fbc2 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -13,6 +13,7 @@ import type { TieredSkill } from "~/features/mmr/tiered.server"; import type { Notification as NotificationValue } from "~/features/notifications/notifications-types"; import type { ScrimFilters } from "~/features/scrims/scrims-types"; import type { TEAM_MEMBER_ROLES } from "~/features/team/team-constants"; +import type { TournamentTierNumber } from "~/features/tournament/core/tiering"; import type * as PickBan from "~/features/tournament-bracket/core/PickBan"; import type * as Progression from "~/features/tournament-bracket/core/Progression"; import type { ParticipantResult } from "~/modules/brackets-model"; @@ -497,6 +498,8 @@ export interface Tournament { isFinalized: Generated; /** Snapshot of teams and rosters when seeds were last saved. Used to detect NEW teams/players. */ seedingSnapshot: JSONColumnTypeNullable; + /** Tournament tier based on top teams' skill. 1=X, 2=S+, 3=S, 4=A+, 5=A, 6=B+, 7=B, 8=C+, 9=C */ + tier: TournamentTierNumber | null; } export interface SeedingSnapshot { @@ -766,6 +769,7 @@ export interface TournamentOrganizationSeries { description: string | null; substringMatches: JSONColumnType; showLeaderboard: Generated; + tierHistory: JSONColumnTypeNullable; } export interface TournamentBracketProgressionOverride { diff --git a/app/features/api-private/routes/seed.ts b/app/features/api-private/routes/seed.ts index 48747b671..bcfe5843f 100644 --- a/app/features/api-private/routes/seed.ts +++ b/app/features/api-private/routes/seed.ts @@ -8,6 +8,7 @@ import { SEED_VARIATIONS } from "~/features/api-private/constants"; import { refreshBannedCache } from "~/features/ban/core/banned.server"; import { refreshSendouQInstance } from "~/features/sendouq/core/SendouQ.server"; import { clearAllTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server"; +import { refreshTentativeTiersCache } from "~/features/tournament-organization/core/tentativeTiers.server"; import { cache } from "~/utils/cache.server"; import { parseRequestPayload } from "~/utils/remix.server"; @@ -52,6 +53,7 @@ export const action: ActionFunction = async ({ request }) => { cache.clear(); await refreshBannedCache(); await refreshSendouQInstance(); + await refreshTentativeTiersCache(); return Response.json(null); }; diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index c387bc69e..916709048 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -1,3 +1,4 @@ +import { sub } from "date-fns"; import type { Expression, ExpressionBuilder, @@ -17,6 +18,7 @@ import type { } from "~/db/tables"; import { EXCLUDED_TAGS } from "~/features/calendar/calendar-constants"; import * as Progression from "~/features/tournament-bracket/core/Progression"; +import { getTentativeTier } from "~/features/tournament-organization/core/tentativeTiers.server"; import { databaseTimestampNow, databaseTimestampToDate, @@ -168,9 +170,11 @@ function findAllBetweenTwoTimestampsQuery({ .select((eb) => [ "CalendarEvent.id as eventId", "CalendarEvent.authorId", + "CalendarEvent.organizationId", "Tournament.id as tournamentId", "Tournament.settings as tournamentSettings", "Tournament.mapPickingStyle", + "Tournament.tier", "CalendarEvent.name", "CalendarEvent.tags", "CalendarEventDate.startTime", @@ -227,6 +231,16 @@ function findAllBetweenTwoTimestampsMapped( ? (row.tags.split(",") as CalendarEvent["tags"]) : []; + const isPastEvent = + databaseTimestampToDate(row.startTime) < sub(new Date(), { days: 1 }); + const tentativeTier = + row.tier === null && + row.organizationId !== null && + row.tournamentId !== null && + !isPastEvent + ? getTentativeTier(row.organizationId, row.name) + : null; + return { at: databaseTimestampToJavascriptTimestamp(row.startTime), type: "calendar", @@ -261,6 +275,8 @@ function findAllBetweenTwoTimestampsMapped( isTest: row.tournamentSettings.isTest ?? false, }) : null, + tier: row.tier ?? null, + tentativeTier, }; }, ); diff --git a/app/features/calendar/calendar-types.ts b/app/features/calendar/calendar-types.ts index 226d7f284..574426d13 100644 --- a/app/features/calendar/calendar-types.ts +++ b/app/features/calendar/calendar-types.ts @@ -12,6 +12,10 @@ interface CommonEvent { url: string; /** Is the tournament ranked? If null, tournament is not hosted on sendou.ink */ isRanked: boolean | null; + /** Tournament tier (1=X, 2=S+, 3=S, 4=A+, 5=A, 6=B+, 7=B, 8=C+, 9=C). Null if not tiered. */ + tier: number | null; + /** Tentative tier prediction based on series history. Displayed with ~ prefix. */ + tentativeTier: number | null; modes: Array | null; organization: { name: string; diff --git a/app/features/calendar/components/TournamentCard.module.css b/app/features/calendar/components/TournamentCard.module.css index 91bf34921..22345855d 100644 --- a/app/features/calendar/components/TournamentCard.module.css +++ b/app/features/calendar/components/TournamentCard.module.css @@ -57,10 +57,17 @@ font-weight: var(--semi-bold); color: var(--text-lighter); display: block; + text-align: center; +} + +.nameRow { + display: flex; + justify-content: center; + align-items: center; + gap: var(--s-2); } .name { - text-align: center; font-weight: var(--semi-bold); font-size: var(--fonts-sm); white-space: nowrap; @@ -69,6 +76,10 @@ overflow: hidden; } +.nameWithTier { + max-width: 165px; +} + .teamCount { font-size: var(--fonts-xxs); font-weight: var(--bold); diff --git a/app/features/calendar/components/TournamentCard.tsx b/app/features/calendar/components/TournamentCard.tsx index 8ce053917..5533bf129 100644 --- a/app/features/calendar/components/TournamentCard.tsx +++ b/app/features/calendar/components/TournamentCard.tsx @@ -7,6 +7,7 @@ import { Flag } from "~/components/Flag"; import { Image, ModeImage } from "~/components/Image"; import { TrophyIcon } from "~/components/icons/Trophy"; import { UsersIcon } from "~/components/icons/Users"; +import { TierPill } from "~/components/TierPill"; import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay"; import { useIsMounted } from "~/hooks/useIsMounted"; import { useTimeFormat } from "~/hooks/useTimeFormat"; @@ -79,25 +80,37 @@ export function TournamentCard({ ) : null}
- {tournament.name}{" "} - {isShowcase ? ( - +
+ {tournament.name} +
+ {tournament.tier ? ( + + ) : tournament.tentativeTier ? ( + ) : null}
+ {isShowcase ? ( + + ) : null} {isCalendar ? (
diff --git a/app/features/calendar/core/CalendarEvent.test.ts b/app/features/calendar/core/CalendarEvent.test.ts index 4aa470daa..c18a41198 100644 --- a/app/features/calendar/core/CalendarEvent.test.ts +++ b/app/features/calendar/core/CalendarEvent.test.ts @@ -12,6 +12,8 @@ function makeEvent( at: Date.now(), id: 1, isRanked: null, + tier: null, + tentativeTier: null, tags: [], modes: ["SZ"], teamsCount: 2, diff --git a/app/features/front-page/core/ShowcaseTournaments.server.ts b/app/features/front-page/core/ShowcaseTournaments.server.ts index 84c582953..87fa768c4 100644 --- a/app/features/front-page/core/ShowcaseTournaments.server.ts +++ b/app/features/front-page/core/ShowcaseTournaments.server.ts @@ -5,6 +5,7 @@ import { getBracketProgressionLabel, tournamentIsRanked, } from "~/features/tournament/tournament-utils"; +import { getTentativeTier } from "~/features/tournament-organization/core/tentativeTiers.server"; import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server"; import { databaseTimestampToDate, @@ -169,7 +170,6 @@ async function cachedTournaments() { ttl: ttl(IN_MILLISECONDS.TWO_HOURS), async getFreshValue() { const tournaments = await TournamentRepository.forShowcase(); - const mapped = tournaments.map(mapTournamentFromDB); return deleteExtraResults(mapped); @@ -276,6 +276,13 @@ function mapTournamentFromDB( ): ShowcaseCalendarEvent { const highestDivWinners = resolveHighestDivisionWinners(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), @@ -297,8 +304,10 @@ function mapTournamentFromDB( minMembersPerTeam: tournament.settings.minMembersPerTeam ?? 4, isTest: tournament.settings.isTest ?? false, }), + tier: tournament.tier ?? null, + tentativeTier, hidden: Boolean(tournament.hidden), - modes: null, // no need to show modes for front page, maybe could in the future? + modes: null, firstPlacer: highestDivWinners.length > 0 ? { diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.finalize.server.ts b/app/features/tournament-bracket/actions/to.$id.brackets.finalize.server.ts index 0327ce153..534b32cf7 100644 --- a/app/features/tournament-bracket/actions/to.$id.brackets.finalize.server.ts +++ b/app/features/tournament-bracket/actions/to.$id.brackets.finalize.server.ts @@ -28,6 +28,8 @@ import { type TournamentBadgeReceivers, } from "~/features/tournament-bracket/tournament-bracket-schemas.server"; import { validateBadgeReceivers } from "~/features/tournament-bracket/tournament-bracket-utils"; +import { refreshTentativeTiersCache } from "~/features/tournament-organization/core/tentativeTiers.server"; +import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server"; import invariant from "~/utils/invariant"; import { logger } from "~/utils/logger"; import { @@ -106,6 +108,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { finalizeTournament(tournamentId); } + await updateSeriesTierHistory(tournament); + if (tournament.ranked) { try { refreshUserSkills(season!); @@ -177,3 +181,25 @@ async function notifyBadgeReceivers(badgeReceivers: TournamentBadgeReceivers) { logger.error("Error notifying badge receivers", error); } } + +async function updateSeriesTierHistory(tournament: Tournament) { + const organizationId = tournament.ctx.organization?.id; + if (!organizationId) return; + + const tier = tournament.ctx.tier; + if (tier === null) return; + + try { + await TournamentOrganizationRepository.updateSeriesTierHistory({ + organizationId, + eventName: tournament.ctx.name, + newTier: tier, + }); + await refreshTentativeTiersCache(); + logger.info( + `Updated series tier history for tournament ${tournament.ctx.id} with tier ${tier}`, + ); + } catch (error) { + logger.error("Error updating series tier history", error); + } +} diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts index 85dc1f6b4..113150adb 100644 --- a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts +++ b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts @@ -3,6 +3,10 @@ import { sql } from "~/db/sql"; import { requireUser } from "~/features/auth/core/user.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import { notify } from "~/features/notifications/core/notify.server"; +import { + calculateTournamentTierFromTeams, + MIN_TEAMS_FOR_TIERING, +} from "~/features/tournament/core/tiering"; import { createSwissBracketInTransaction } from "~/features/tournament/queries/createSwissBracketInTransaction.server"; import { updateRoundMaps } from "~/features/tournament/queries/updateRoundMaps.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; @@ -132,6 +136,24 @@ export const action: ActionFunction = async ({ params, request }) => { }); } + if (data.bracketIdx === 0 && seeding.length >= MIN_TEAMS_FOR_TIERING) { + const checkedInTeams = tournament.ctx.teams + .filter((team) => seeding.includes(team.id)) + .map((team) => ({ avgOrdinal: team.avgSeedingSkillOrdinal })); + + const { tierNumber } = calculateTournamentTierFromTeams( + checkedInTeams, + seeding.length, + ); + + if (tierNumber !== null) { + await TournamentRepository.updateTournamentTier({ + tournamentId: tournament.ctx.id, + tier: tierNumber, + }); + } + } + if (!tournament.isTest) { notify({ userIds: seeding.flatMap((tournamentTeamId) => diff --git a/app/features/tournament-bracket/core/Tournament.server.ts b/app/features/tournament-bracket/core/Tournament.server.ts index 71e4d2f91..23ca6f6cf 100644 --- a/app/features/tournament-bracket/core/Tournament.server.ts +++ b/app/features/tournament-bracket/core/Tournament.server.ts @@ -1,4 +1,5 @@ import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import { getTentativeTier } from "~/features/tournament-organization/core/tentativeTiers.server"; import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types"; import { isAdmin } from "~/modules/permissions/utils"; import { notFoundIfFalsy } from "~/utils/remix.server"; @@ -54,10 +55,16 @@ function dataMapped({ isAdmin(user); const revealInfo = tournamentHasStarted || isOrganizer; + const tentativeTier = + !ctx.tier && ctx.organization?.id + ? getTentativeTier(ctx.organization.id, ctx.name) + : null; + return { data, ctx: { ...ctx, + tentativeTier, teams: ctx.teams.map((team) => { const isOwnTeam = team.members.some( (member) => member.userId === user?.id, diff --git a/app/features/tournament-bracket/core/tests/mocks-li.ts b/app/features/tournament-bracket/core/tests/mocks-li.ts index 6d28f839e..2f4f80fd7 100644 --- a/app/features/tournament-bracket/core/tests/mocks-li.ts +++ b/app/features/tournament-bracket/core/tests/mocks-li.ts @@ -6834,6 +6834,8 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ id: 815, eventId: 2614, parentTournamentId: null, + tier: null, + tentativeTier: null, discordUrl: "https://discord.gg/F7RaNUR", tags: "LOW", settings: { diff --git a/app/features/tournament-bracket/core/tests/mocks-sos.ts b/app/features/tournament-bracket/core/tests/mocks-sos.ts index 35d438639..23cf3ae3e 100644 --- a/app/features/tournament-bracket/core/tests/mocks-sos.ts +++ b/app/features/tournament-bracket/core/tests/mocks-sos.ts @@ -1934,6 +1934,8 @@ export const SWIM_OR_SINK_167 = ( ctx: { id: 672, parentTournamentId: null, + tier: null, + tentativeTier: null, eventId: 2425, discordUrl: "https://discord.gg/F7RaNUR", tags: null, diff --git a/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts b/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts index 47eba1336..1ee611b7f 100644 --- a/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts +++ b/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts @@ -288,6 +288,8 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({ ctx: { id: 891, eventId: 2698, + tier: null, + tentativeTier: null, discordUrl: "https://discord.gg/A6NW3VCHRh", tags: "REGION,SZ", settings: { diff --git a/app/features/tournament-bracket/core/tests/mocks.ts b/app/features/tournament-bracket/core/tests/mocks.ts index 7f8e5afa6..5564cb1ff 100644 --- a/app/features/tournament-bracket/core/tests/mocks.ts +++ b/app/features/tournament-bracket/core/tests/mocks.ts @@ -1396,6 +1396,8 @@ export const PADDLING_POOL_257 = () => ctx: { id: 27, organization: null, + tier: null, + tentativeTier: null, parentTournamentId: null, tags: null, eventId: 1352, @@ -8001,6 +8003,8 @@ export const PADDLING_POOL_255 = () => ctx: { id: 18, organization: null, + tier: null, + tentativeTier: null, parentTournamentId: null, tags: null, eventId: 1286, @@ -14946,6 +14950,8 @@ export const IN_THE_ZONE_32 = ({ id: 11, parentTournamentId: null, organization: null, + tier: null, + tentativeTier: null, tags: null, eventId: 1134, bracketProgressionOverrides: [], diff --git a/app/features/tournament-bracket/core/tests/test-utils.ts b/app/features/tournament-bracket/core/tests/test-utils.ts index 0ed7e0566..33ac413d4 100644 --- a/app/features/tournament-bracket/core/tests/test-utils.ts +++ b/app/features/tournament-bracket/core/tests/test-utils.ts @@ -63,6 +63,8 @@ export const testTournament = ({ tags: null, description: null, organization: null, + tier: null, + tentativeTier: null, parentTournamentId: null, rules: null, logoUrl: "/test.png", diff --git a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts index c3c1407d0..a34136e40 100644 --- a/app/features/tournament-organization/TournamentOrganizationRepository.server.ts +++ b/app/features/tournament-organization/TournamentOrganizationRepository.server.ts @@ -3,6 +3,10 @@ import { sql } from "kysely"; import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite"; import { db } from "~/db/sql"; import type { Tables, TablesInsertable } from "~/db/tables"; +import { + type TournamentTierNumber, + updateTierHistory, +} from "~/features/tournament/core/tiering"; import { databaseTimestampNow, databaseTimestampToDate, @@ -92,6 +96,7 @@ export async function findBySlug(slug: string) { "TournamentOrganizationSeries.substringMatches", "TournamentOrganizationSeries.showLeaderboard", "TournamentOrganizationSeries.description", + "TournamentOrganizationSeries.tierHistory", ]) .whereRef( "TournamentOrganizationSeries.organizationId", @@ -553,3 +558,43 @@ export function updateIsEstablished( .where("id", "=", organizationId) .execute(); } + +export function findAllSeriesWithTierHistory() { + return db + .selectFrom("TournamentOrganizationSeries") + .select(["organizationId", "substringMatches", "tierHistory"]) + .execute(); +} + +export async function updateSeriesTierHistory({ + organizationId, + eventName, + newTier, +}: { + organizationId: number; + eventName: string; + newTier: TournamentTierNumber; +}) { + const series = await db + .selectFrom("TournamentOrganizationSeries") + .select(["id", "substringMatches", "tierHistory"]) + .where("organizationId", "=", organizationId) + .execute(); + + const eventNameLower = eventName.toLowerCase(); + const matchingSeries = series.find((s) => + s.substringMatches.some((match) => + eventNameLower.includes(match.toLowerCase()), + ), + ); + + if (!matchingSeries) return; + + const newTierHistory = updateTierHistory(matchingSeries.tierHistory, newTier); + + await db + .updateTable("TournamentOrganizationSeries") + .set({ tierHistory: JSON.stringify(newTierHistory) }) + .where("id", "=", matchingSeries.id) + .execute(); +} diff --git a/app/features/tournament-organization/core/tentativeTiers.server.ts b/app/features/tournament-organization/core/tentativeTiers.server.ts new file mode 100644 index 000000000..70674e4e1 --- /dev/null +++ b/app/features/tournament-organization/core/tentativeTiers.server.ts @@ -0,0 +1,49 @@ +import { calculateTentativeTier } from "~/features/tournament/core/tiering"; +import * as TournamentOrganizationRepository from "../TournamentOrganizationRepository.server"; + +interface SeriesMatch { + substringMatches: string[]; + tentativeTier: number; +} + +async function loadCache(): Promise> { + const rows = + await TournamentOrganizationRepository.findAllSeriesWithTierHistory(); + + const result = new Map(); + for (const row of rows) { + if (!row.tierHistory?.length) continue; + + const tentativeTier = calculateTentativeTier(row.tierHistory); + if (tentativeTier === null) continue; + + const existing = result.get(row.organizationId) ?? []; + existing.push({ + substringMatches: row.substringMatches, + tentativeTier, + }); + result.set(row.organizationId, existing); + } + return result; +} + +let cache = await loadCache(); + +export function getTentativeTier( + orgId: number, + tournamentName: string, +): number | null { + const seriesList = cache.get(orgId); + if (!seriesList) return null; + + const nameLower = tournamentName.toLowerCase(); + const match = seriesList.find((s) => + s.substringMatches.some((m) => nameLower.includes(m.toLowerCase())), + ); + + return match?.tentativeTier ?? null; +} + +export async function refreshTentativeTiersCache(): Promise { + cache = await loadCache(); +} diff --git a/app/features/tournament-organization/loaders/org.$slug.server.ts b/app/features/tournament-organization/loaders/org.$slug.server.ts index d378325b2..e2a290784 100644 --- a/app/features/tournament-organization/loaders/org.$slug.server.ts +++ b/app/features/tournament-organization/loaders/org.$slug.server.ts @@ -1,6 +1,7 @@ import type { LoaderFunctionArgs } from "react-router"; import { z } from "zod"; import { getUser } from "~/features/auth/core/user.server"; +import { calculateTentativeTier } from "~/features/tournament/core/tiering"; import type { SerializeFrom } from "~/utils/remix"; import { parseSafeSearchParams } from "~/utils/remix.server"; import { id } from "~/utils/zod"; @@ -52,6 +53,9 @@ export async function loader({ params, request }: LoaderFunctionArgs) { description: series.description, page, leaderboard: series.showLeaderboard ? leaderboard : null, + tentativeTier: series.tierHistory + ? calculateTentativeTier(series.tierHistory) + : null, ...rest, }; }; diff --git a/app/features/tournament-organization/routes/org.$slug.tsx b/app/features/tournament-organization/routes/org.$slug.tsx index b11cae859..ee7a2a18c 100644 --- a/app/features/tournament-organization/routes/org.$slug.tsx +++ b/app/features/tournament-organization/routes/org.$slug.tsx @@ -18,6 +18,7 @@ import { UsersIcon } from "~/components/icons/Users"; import { Main } from "~/components/Main"; import { Pagination } from "~/components/Pagination"; import { Placement } from "~/components/Placement"; +import { TierPill } from "~/components/TierPill"; import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay"; import { BannedUsersList } from "~/features/tournament-organization/components/BannedPlayersList"; import { SendouForm } from "~/form/SendouForm"; @@ -329,7 +330,12 @@ function SeriesHeader({ /> ) : null}
-

{series.name}

+
+

{series.name}

+ {series.tentativeTier ? ( + + ) : null} +
{series.established ? (
{t("org:events.established.short")}{" "} diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index 6efb4cdd9..e8c5632f6 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -22,6 +22,7 @@ import { userChatNameColor, } from "~/utils/kysely.server"; import type { Unwrapped } from "~/utils/types"; +import type { TournamentTierNumber } from "./core/tiering"; export type FindById = NonNullable>; export async function findById(id: number) { @@ -51,6 +52,7 @@ export async function findById(id: number) { "Tournament.mapPickingStyle", "Tournament.rules", "Tournament.parentTournamentId", + "Tournament.tier", "CalendarEvent.name", "CalendarEvent.description", "CalendarEventDate.startTime", @@ -464,8 +466,10 @@ export function forShowcase() { .select((eb) => [ "Tournament.id", "Tournament.settings", + "Tournament.tier", "CalendarEvent.authorId", "CalendarEvent.name", + "CalendarEvent.organizationId", "CalendarEventDate.startTime", "CalendarEvent.hidden", eb @@ -1234,3 +1238,17 @@ export function updateTeamSeeds({ .execute(); }); } + +export function updateTournamentTier({ + tournamentId, + tier, +}: { + tournamentId: number; + tier: TournamentTierNumber; +}) { + return db + .updateTable("Tournament") + .set({ tier }) + .where("id", "=", tournamentId) + .execute(); +} diff --git a/app/features/tournament/core/tiering.test.ts b/app/features/tournament/core/tiering.test.ts new file mode 100644 index 000000000..d2c51b429 --- /dev/null +++ b/app/features/tournament/core/tiering.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from "vitest"; +import { + calculateAdjustedScore, + calculateTentativeTier, + calculateTierNumber, + calculateTournamentTierFromTeams, + MIN_TEAMS_FOR_TIERING, + TIER_HISTORY_LENGTH, + TIER_THRESHOLDS, + TIER_TO_NUMBER, + type TournamentTierNumber, + tierNumberToName, + updateTierHistory, +} from "./tiering"; + +describe("calculateTierNumber", () => { + it("returns null for null input", () => { + expect(calculateTierNumber(null)).toBe(null); + }); + + it("returns X tier (1) for scores >= 32", () => { + expect(calculateTierNumber(32)).toBe(1); + expect(calculateTierNumber(35)).toBe(1); + expect(calculateTierNumber(100)).toBe(1); + }); + + it("returns A tier (5) for scores >= 21 and < 24", () => { + expect(calculateTierNumber(21)).toBe(5); + expect(calculateTierNumber(23.9)).toBe(5); + }); + + it("returns C tier (9) for scores < 5", () => { + expect(calculateTierNumber(4.9)).toBe(9); + expect(calculateTierNumber(0)).toBe(9); + expect(calculateTierNumber(-10)).toBe(9); + }); +}); + +describe("tierNumberToName", () => { + it("converts all tier numbers correctly", () => { + expect(tierNumberToName(1)).toBe("X"); + expect(tierNumberToName(2)).toBe("S+"); + expect(tierNumberToName(3)).toBe("S"); + expect(tierNumberToName(4)).toBe("A+"); + expect(tierNumberToName(5)).toBe("A"); + expect(tierNumberToName(6)).toBe("B+"); + expect(tierNumberToName(7)).toBe("B"); + expect(tierNumberToName(8)).toBe("C+"); + expect(tierNumberToName(9)).toBe("C"); + }); + + it("throws for invalid tier numbers", () => { + expect(() => tierNumberToName(0)).toThrow("Invalid tier number: 0"); + expect(() => tierNumberToName(10)).toThrow("Invalid tier number: 10"); + }); +}); + +describe("calculateAdjustedScore", () => { + it("returns raw score when at X-tier threshold (no bonus)", () => { + expect(calculateAdjustedScore(32, 50)).toBe(32); + }); + + it("returns raw score when below MIN_TEAMS_FOR_TIERING", () => { + expect(calculateAdjustedScore(20, MIN_TEAMS_FOR_TIERING)).toBe(20); + }); + + it("applies size bonus for lower skill tournaments with many teams", () => { + const rawScore = 20; + const teamCount = 50; + const adjusted = calculateAdjustedScore(rawScore, teamCount); + + expect(adjusted).toBeGreaterThan(rawScore); + }); + + it("applies larger bonus at lower skill levels", () => { + const teamCount = 50; + const bonusAtScore20 = calculateAdjustedScore(20, teamCount) - 20; + const bonusAtScore10 = calculateAdjustedScore(10, teamCount) - 10; + + expect(bonusAtScore10).toBeGreaterThan(bonusAtScore20); + }); + + it("applies larger bonus for more teams", () => { + const rawScore = 15; + const bonusAt30Teams = calculateAdjustedScore(rawScore, 30) - rawScore; + const bonusAt50Teams = calculateAdjustedScore(rawScore, 50) - rawScore; + + expect(bonusAt50Teams).toBeGreaterThan(bonusAt30Teams); + }); + + it("calculates correct bonus for specific case", () => { + const rawScore = 0; + const teamCount = 18; + const teamsAboveMin = teamCount - MIN_TEAMS_FOR_TIERING; + const maxBonus = 1.5 * (teamsAboveMin / 10); + + const adjusted = calculateAdjustedScore(rawScore, teamCount); + expect(adjusted).toBeCloseTo(rawScore + maxBonus, 5); + }); +}); + +describe("calculateTournamentTierFromTeams", () => { + it("returns null tier for tournaments below MIN_TEAMS_FOR_TIERING", () => { + const teams = [{ avgOrdinal: 30 }, { avgOrdinal: 28 }]; + const result = calculateTournamentTierFromTeams(teams, 7); + + expect(result.tierNumber).toBe(null); + expect(result.rawScore).toBe(null); + expect(result.adjustedScore).toBe(null); + }); + + it("returns null tier when all teams have null ordinals", () => { + const teams = [{ avgOrdinal: null }, { avgOrdinal: null }]; + const result = calculateTournamentTierFromTeams(teams, 10); + + expect(result.tierNumber).toBe(null); + }); + + it("returns null tier for empty teams array with sufficient count", () => { + const result = calculateTournamentTierFromTeams([], 10); + + expect(result.tierNumber).toBe(null); + }); + + it("calculates tier from top 8 teams by ordinal", () => { + const teams = [ + { avgOrdinal: 35 }, + { avgOrdinal: 34 }, + { avgOrdinal: 33 }, + { avgOrdinal: 32 }, + { avgOrdinal: 31 }, + { avgOrdinal: 30 }, + { avgOrdinal: 29 }, + { avgOrdinal: 28 }, + { avgOrdinal: 10 }, + { avgOrdinal: 5 }, + ]; + const result = calculateTournamentTierFromTeams(teams, 10); + + expect(result.rawScore).toBeCloseTo(31.5, 5); + expect(result.tierNumber).toBe(TIER_TO_NUMBER["S+"]); + }); + + it("ignores teams with null ordinals when calculating", () => { + const teams = [ + { avgOrdinal: 30 }, + { avgOrdinal: null }, + { avgOrdinal: 28 }, + { avgOrdinal: null }, + { avgOrdinal: 26 }, + { avgOrdinal: 24 }, + { avgOrdinal: 22 }, + { avgOrdinal: 20 }, + { avgOrdinal: 18 }, + { avgOrdinal: 16 }, + ]; + const result = calculateTournamentTierFromTeams(teams, 10); + + expect(result.tierNumber).not.toBe(null); + }); + + it("applies size bonus correctly", () => { + const teams = Array.from({ length: 50 }, () => ({ avgOrdinal: 20 })); + const result = calculateTournamentTierFromTeams(teams, 50); + + expect(result.adjustedScore).toBeGreaterThan(result.rawScore!); + }); + + it("uses fewer than 8 teams if not enough available", () => { + const teams = [{ avgOrdinal: 30 }, { avgOrdinal: 28 }, { avgOrdinal: 26 }]; + const result = calculateTournamentTierFromTeams(teams, 8); + + expect(result.rawScore).toBeCloseTo(28, 5); + expect(result.tierNumber).not.toBe(null); + }); +}); + +describe("TIER_THRESHOLDS matches TIER_TO_NUMBER", () => { + it("all tiers in TIER_THRESHOLDS have corresponding number", () => { + for (const tier of Object.keys(TIER_THRESHOLDS)) { + expect(TIER_TO_NUMBER).toHaveProperty(tier); + } + }); +}); + +describe("calculateTentativeTier", () => { + it("returns null for empty history", () => { + expect(calculateTentativeTier([])).toBe(null); + }); + + it("returns the single value for history with one element", () => { + expect(calculateTentativeTier([4])).toBe(4); + }); + + it("returns median for odd number of elements", () => { + expect(calculateTentativeTier([5, 4, 3])).toBe(4); + expect(calculateTentativeTier([1, 2, 3, 4, 5])).toBe(3); + }); + + it("rounds toward lower tier (higher number) for even count", () => { + expect(calculateTentativeTier([4, 5])).toBe(5); + expect(calculateTentativeTier([3, 4])).toBe(4); + }); + + it("returns median for unsorted input", () => { + expect(calculateTentativeTier([3, 5, 4, 2, 6])).toBe(4); + }); + + it("handles duplicate values correctly", () => { + expect(calculateTentativeTier([4, 4, 4])).toBe(4); + expect(calculateTentativeTier([5, 5, 4, 4])).toBe(5); + }); +}); + +describe("updateTierHistory", () => { + it("creates new history from null", () => { + expect(updateTierHistory(null, 4)).toEqual([4]); + }); + + it("appends to existing history", () => { + expect(updateTierHistory([5, 4], 3)).toEqual([5, 4, 3]); + }); + + it("keeps history at max length", () => { + const fullHistory: TournamentTierNumber[] = [1, 2, 3, 4, 5]; + expect(updateTierHistory(fullHistory, 6)).toEqual([2, 3, 4, 5, 6]); + }); + + it("preserves history under max length", () => { + expect(updateTierHistory([4, 5], 3)).toEqual([4, 5, 3]); + }); + + it("slices from beginning when exceeding max length", () => { + const history: TournamentTierNumber[] = [1, 2, 3, 4, 5]; + const result = updateTierHistory(history, 9); + expect(result.length).toBe(TIER_HISTORY_LENGTH); + expect(result[result.length - 1]).toBe(9); + expect(result[0]).toBe(2); + }); +}); diff --git a/app/features/tournament/core/tiering.ts b/app/features/tournament/core/tiering.ts new file mode 100644 index 000000000..a4c0459b9 --- /dev/null +++ b/app/features/tournament/core/tiering.ts @@ -0,0 +1,148 @@ +/** + * Tournament tier calculation based on top teams' average SeedingSkill ordinal. + * Lower tier number = better tier (like placements). + */ + +export const TIER_THRESHOLDS = { + X: 32, + "S+": 29, + S: 26, + "A+": 24, + A: 21, + "B+": 15, + B: 10, + "C+": 5, + C: Number.NEGATIVE_INFINITY, +} as const; + +export const TOP_TEAMS_COUNT = 8; +export const MIN_TEAMS_FOR_TIERING = 8; +export const TIER_HISTORY_LENGTH = 5; + +const SIZE_BONUS = { + NO_BONUS_ABOVE: 32, + MAX_BONUS_PER_10_TEAMS: 1.5, +} as const; + +export const TIER_TO_NUMBER = { + X: 1, + "S+": 2, + S: 3, + "A+": 4, + A: 5, + "B+": 6, + B: 7, + "C+": 8, + C: 9, +} as const; + +const NUMBER_TO_TIER = { + 1: "X", + 2: "S+", + 3: "S", + 4: "A+", + 5: "A", + 6: "B+", + 7: "B", + 8: "C+", + 9: "C", +} as const; + +export type TournamentTier = keyof typeof TIER_TO_NUMBER; +export type TournamentTierNumber = (typeof TIER_TO_NUMBER)[TournamentTier]; + +export function calculateAdjustedScore( + rawScore: number, + teamCount: number, +): number { + const scaleFactor = Math.max( + 0, + (SIZE_BONUS.NO_BONUS_ABOVE - rawScore) / SIZE_BONUS.NO_BONUS_ABOVE, + ); + + const teamsAboveMin = Math.max(0, teamCount - MIN_TEAMS_FOR_TIERING); + const bonus = + scaleFactor * SIZE_BONUS.MAX_BONUS_PER_10_TEAMS * (teamsAboveMin / 10); + + return rawScore + bonus; +} + +export function calculateTierNumber( + score: number | null, +): TournamentTierNumber | null { + if (score === null) return null; + + const tiers = Object.entries(TIER_THRESHOLDS) as [TournamentTier, number][]; + for (const [tier, threshold] of tiers) { + if (score >= threshold) return TIER_TO_NUMBER[tier]; + } + + return TIER_TO_NUMBER.C; +} + +export function tierNumberToName(tierNumber: number): TournamentTier { + const tier = NUMBER_TO_TIER[tierNumber as TournamentTierNumber]; + if (!tier) { + throw new Error(`Invalid tier number: ${tierNumber}`); + } + return tier; +} + +export function calculateTournamentTierFromTeams( + teams: Array<{ avgOrdinal: number | null }>, + totalTeamCount: number, +): { + tierNumber: TournamentTierNumber | null; + rawScore: number | null; + adjustedScore: number | null; +} { + if (totalTeamCount < MIN_TEAMS_FOR_TIERING) { + return { tierNumber: null, rawScore: null, adjustedScore: null }; + } + + const teamsWithOrdinal = teams.filter( + (t): t is { avgOrdinal: number } => t.avgOrdinal !== null, + ); + + if (teamsWithOrdinal.length === 0) { + return { tierNumber: null, rawScore: null, adjustedScore: null }; + } + + const topTeams = teamsWithOrdinal + .sort((a, b) => b.avgOrdinal - a.avgOrdinal) + .slice(0, TOP_TEAMS_COUNT); + + const rawScore = + topTeams.reduce((sum, t) => sum + t.avgOrdinal, 0) / topTeams.length; + const adjustedScore = calculateAdjustedScore(rawScore, totalTeamCount); + const tierNumber = calculateTierNumber(adjustedScore); + + return { tierNumber, rawScore, adjustedScore }; +} + +export function calculateTentativeTier( + tierHistory: TournamentTierNumber[], +): TournamentTierNumber | null { + if (tierHistory.length === 0) return null; + + const sorted = [...tierHistory].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + + if (sorted.length % 2 === 0) { + return Math.ceil( + (sorted[mid - 1] + sorted[mid]) / 2, + ) as TournamentTierNumber; + } + return sorted[mid]; +} + +export function updateTierHistory( + currentHistory: TournamentTierNumber[] | null, + newTier: TournamentTierNumber, +): TournamentTierNumber[] { + const history = currentHistory ?? []; + const updated = [...history, newTier]; + return updated.length > TIER_HISTORY_LENGTH + ? updated.slice(-TIER_HISTORY_LENGTH) + : updated; +} diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index b95405e7a..6bb7cc4e6 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -31,6 +31,7 @@ import { MapPoolStages } from "~/components/MapPoolSelector"; import { Markdown } from "~/components/Markdown"; import { Section } from "~/components/Section"; import { SubmitButton } from "~/components/SubmitButton"; +import { TierPill } from "~/components/TierPill"; import TimePopover from "~/components/TimePopover"; import { useUser } from "~/features/auth/core/user"; import { imgTypeToDimensions } from "~/features/img-upload/upload-constants"; @@ -139,6 +140,11 @@ export default function TournamentRegisterPage() { Unranked
)} + {tournament.ctx.tier ? ( + + ) : tournament.ctx.tentativeTier && !tournament.hasStarted ? ( + + ) : null}
{tournament.modesIncluded.map((mode) => ( diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index 57b1dc9f7..ca404708f 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -510,6 +510,7 @@ const baseTournamentResultsQuery = (userId: number) => "CalendarEvent.tournamentId", "TournamentResult.tournamentId", ) + .innerJoin("Tournament", "Tournament.id", "TournamentResult.tournamentId") .where("TournamentResult.userId", "=", userId); export function findResultsByUserId( @@ -537,6 +538,7 @@ export function findResultsByUserId( sql`1`, sql`0`, ]).as("isHighlight"), + sql`null`.as("tier"), withMaxEventStartTime(eb), jsonArrayFrom( eb @@ -571,6 +573,7 @@ export function findResultsByUserId( "TournamentTeam.id as teamId", "TournamentTeam.name as teamName", "TournamentResult.isHighlight", + "Tournament.tier", withMaxEventStartTime(eb), jsonArrayFrom( eb diff --git a/app/features/user-page/components/UserResultsTable.tsx b/app/features/user-page/components/UserResultsTable.tsx index 89fdba903..dd8ea286f 100644 --- a/app/features/user-page/components/UserResultsTable.tsx +++ b/app/features/user-page/components/UserResultsTable.tsx @@ -6,6 +6,7 @@ import { SendouPopover } from "~/components/elements/Popover"; import { UsersIcon } from "~/components/icons/Users"; import { Placement } from "~/components/Placement"; import { Table } from "~/components/Table"; +import { TierPill } from "~/components/TierPill"; import { useTimeFormat } from "~/hooks/useTimeFormat"; import { databaseTimestampToDate } from "~/utils/dates"; import { @@ -117,6 +118,7 @@ export function UserResultsTable({ > {result.eventName} + {result.tier ? : null} {result.div ? ( ({result.div}) ) : null} diff --git a/db-test.sqlite3 b/db-test.sqlite3 index dbc7a4dda..f5216c625 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/e2e/seeds/db-seed-DEFAULT.sqlite3 b/e2e/seeds/db-seed-DEFAULT.sqlite3 index 36a3f93ce..a9fe6feef 100644 Binary files a/e2e/seeds/db-seed-DEFAULT.sqlite3 and b/e2e/seeds/db-seed-DEFAULT.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 index f36a17f86..bff378845 100644 Binary files a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 and b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 index 268644ac4..5411be44b 100644 Binary files a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 and b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 index 809775ba1..8a29ba532 100644 Binary files a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 and b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 index d03ff70b8..b7b2980db 100644 Binary files a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 and b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 differ diff --git a/e2e/seeds/db-seed-REG_OPEN.sqlite3 b/e2e/seeds/db-seed-REG_OPEN.sqlite3 index 5f574e19e..be269a012 100644 Binary files a/e2e/seeds/db-seed-REG_OPEN.sqlite3 and b/e2e/seeds/db-seed-REG_OPEN.sqlite3 differ diff --git a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 index 444be8105..d80c9aaac 100644 Binary files a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 and b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 differ diff --git a/e2e/tournament-tiers.spec.ts b/e2e/tournament-tiers.spec.ts new file mode 100644 index 000000000..d44f60581 --- /dev/null +++ b/e2e/tournament-tiers.spec.ts @@ -0,0 +1,37 @@ +import { + expect, + impersonate, + navigate, + seed, + submit, + test, +} from "~/utils/playwright"; +import { calendarPage, tournamentBracketsPage } from "~/utils/urls"; + +test.describe("Tournament tiers", () => { + test("shows tentative tier before bracket starts and confirmed tier after", async ({ + page, + }) => { + await seed(page); + + await navigate({ page, url: calendarPage() }); + + const picnicCard = page + .getByTestId("tournament-card") + .filter({ hasText: "PICNIC" }); + await expect(picnicCard.getByTestId("tentative-tier")).toBeVisible(); + + await impersonate(page); + await navigate({ page, url: tournamentBracketsPage({ tournamentId: 1 }) }); + + await page.getByTestId("finalize-bracket-button").click(); + await submit(page, "confirm-finalize-bracket-button"); + + await navigate({ page, url: calendarPage() }); + + const picnicCardAfter = page + .getByTestId("tournament-card") + .filter({ hasText: "PICNIC" }); + await expect(picnicCardAfter.getByTestId("confirmed-tier")).toBeVisible(); + }); +}); diff --git a/locales/da/common.json b/locales/da/common.json index 3f72c54a3..d4374b7ff 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -328,5 +328,7 @@ "api.regenerate.heading": "", "api.regenerate.button": "", "api.regenerate.confirm": "", - "api.generate": "" + "api.generate": "", + "tier.tentative": "", + "tier.confirmed": "" } diff --git a/locales/de/common.json b/locales/de/common.json index de256d8db..b09eedacc 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -328,5 +328,7 @@ "api.regenerate.heading": "", "api.regenerate.button": "", "api.regenerate.confirm": "", - "api.generate": "" + "api.generate": "", + "tier.tentative": "", + "tier.confirmed": "" } diff --git a/locales/en/common.json b/locales/en/common.json index c1512f364..2681cd7b8 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -328,5 +328,7 @@ "api.regenerate.heading": "Regenerating will invalidate your current token. Any applications using the old token will stop working.", "api.regenerate.button": "Regenerate token", "api.regenerate.confirm": "Confirm Regenerate", - "api.generate": "Generate Token" + "api.generate": "Generate Token", + "tier.tentative": "Tentative {{tierName}}-tier (based on series history)", + "tier.confirmed": "{{tierName}}-tier tournament" } diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 466733a1c..3db03a59c 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -330,5 +330,7 @@ "api.regenerate.heading": "", "api.regenerate.button": "", "api.regenerate.confirm": "", - "api.generate": "" + "api.generate": "", + "tier.tentative": "", + "tier.confirmed": "" } diff --git a/locales/es-US/common.json b/locales/es-US/common.json index ed6a088b0..b4aebb178 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -330,5 +330,7 @@ "api.regenerate.heading": "", "api.regenerate.button": "", "api.regenerate.confirm": "", - "api.generate": "" + "api.generate": "", + "tier.tentative": "", + "tier.confirmed": "" } diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index 900edde3a..b56a11010 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -330,5 +330,7 @@ "api.regenerate.heading": "", "api.regenerate.button": "", "api.regenerate.confirm": "", - "api.generate": "" + "api.generate": "", + "tier.tentative": "", + "tier.confirmed": "" } diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index 4b3382f37..f44d4ce3c 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -330,5 +330,7 @@ "api.regenerate.heading": "", "api.regenerate.button": "", "api.regenerate.confirm": "", - "api.generate": "" + "api.generate": "", + "tier.tentative": "", + "tier.confirmed": "" } diff --git a/locales/he/common.json b/locales/he/common.json index 0879d187d..42bf19f48 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -329,5 +329,7 @@ "api.regenerate.heading": "", "api.regenerate.button": "", "api.regenerate.confirm": "", - "api.generate": "" + "api.generate": "", + "tier.tentative": "", + "tier.confirmed": "" } diff --git a/locales/it/common.json b/locales/it/common.json index 04e84f6d0..9b4ef4828 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -330,5 +330,7 @@ "api.regenerate.heading": "", "api.regenerate.button": "", "api.regenerate.confirm": "", - "api.generate": "" + "api.generate": "", + "tier.tentative": "", + "tier.confirmed": "" } diff --git a/locales/ja/common.json b/locales/ja/common.json index c5b2fde3a..35bcca096 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -324,5 +324,7 @@ "api.regenerate.heading": "", "api.regenerate.button": "", "api.regenerate.confirm": "", - "api.generate": "" + "api.generate": "", + "tier.tentative": "", + "tier.confirmed": "" } diff --git a/locales/ko/common.json b/locales/ko/common.json index 52729f28d..dc1b8d1c5 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -324,5 +324,7 @@ "api.regenerate.heading": "", "api.regenerate.button": "", "api.regenerate.confirm": "", - "api.generate": "" + "api.generate": "", + "tier.tentative": "", + "tier.confirmed": "" } diff --git a/locales/nl/common.json b/locales/nl/common.json index a102a3a7d..f91da199b 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -328,5 +328,7 @@ "api.regenerate.heading": "", "api.regenerate.button": "", "api.regenerate.confirm": "", - "api.generate": "" + "api.generate": "", + "tier.tentative": "", + "tier.confirmed": "" } diff --git a/locales/pl/common.json b/locales/pl/common.json index f40ab9eee..b9663e093 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -331,5 +331,7 @@ "api.regenerate.heading": "", "api.regenerate.button": "", "api.regenerate.confirm": "", - "api.generate": "" + "api.generate": "", + "tier.tentative": "", + "tier.confirmed": "" } diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index 7d50364a6..9a97d412b 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -330,5 +330,7 @@ "api.regenerate.heading": "", "api.regenerate.button": "", "api.regenerate.confirm": "", - "api.generate": "" + "api.generate": "", + "tier.tentative": "", + "tier.confirmed": "" } diff --git a/locales/ru/common.json b/locales/ru/common.json index 6434bef74..e25c058c0 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -331,5 +331,7 @@ "api.regenerate.heading": "", "api.regenerate.button": "", "api.regenerate.confirm": "", - "api.generate": "" + "api.generate": "", + "tier.tentative": "", + "tier.confirmed": "" } diff --git a/locales/zh/common.json b/locales/zh/common.json index ecff79793..12a7b675a 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -324,5 +324,7 @@ "api.regenerate.heading": "", "api.regenerate.button": "", "api.regenerate.confirm": "", - "api.generate": "" + "api.generate": "", + "tier.tentative": "", + "tier.confirmed": "" } diff --git a/migrations/116-tournament-tier.js b/migrations/116-tournament-tier.js new file mode 100644 index 000000000..efe5263e3 --- /dev/null +++ b/migrations/116-tournament-tier.js @@ -0,0 +1,6 @@ +export function up(db) { + db.prepare(/* sql */ `alter table "Tournament" add "tier" integer`).run(); + db.prepare( + /* sql */ `alter table "TournamentOrganizationSeries" add "tierHistory" text`, + ).run(); +} diff --git a/scripts/backfill-tournament-tiers.ts b/scripts/backfill-tournament-tiers.ts new file mode 100644 index 000000000..b5e805e8e --- /dev/null +++ b/scripts/backfill-tournament-tiers.ts @@ -0,0 +1,257 @@ +/** biome-ignore-all lint/suspicious/noConsole: CLI script output */ +/** + * Backfill Tournament Tiers Script + * + * Run with: npx tsx scripts/backfill-tournament-tiers.ts + * + * Retroactively calculates and sets tiers for all finalized tournaments, + * then populates series tier history based on those tiers. + */ + +import { sql } from "~/db/sql"; +import { + calculateAdjustedScore, + calculateTierNumber, + MIN_TEAMS_FOR_TIERING, + TIER_HISTORY_LENGTH, + TOP_TEAMS_COUNT, +} from "../app/features/tournament/core/tiering"; + +const dryRun = process.argv.includes("--dry-run"); + +interface TournamentScore { + tournamentId: number; + teamCount: number; + top8AvgOrdinal: number | null; +} + +interface TournamentWithOrg { + tournamentId: number; + name: string; + organizationId: number | null; + startTime: number; +} + +interface Series { + id: number; + organizationId: number; + substringMatches: string; +} + +function getTournamentScores(): TournamentScore[] { + const query = ` + WITH TeamSkills AS ( + SELECT + tt.tournamentId, + tt.id as teamId, + AVG(ss.ordinal) as avg_team_ordinal + FROM TournamentTeam tt + JOIN TournamentTeamMember ttm ON ttm.tournamentTeamId = tt.id + LEFT JOIN SeedingSkill ss ON ss.userId = ttm.userId AND ss.type = 'RANKED' + WHERE tt.droppedOut = 0 + GROUP BY tt.tournamentId, tt.id + ), + TeamCounts AS ( + SELECT tournamentId, COUNT(*) as team_count + FROM TeamSkills + WHERE avg_team_ordinal IS NOT NULL + GROUP BY tournamentId + ), + RankedTeams AS ( + SELECT + ts.tournamentId, + ts.avg_team_ordinal, + tc.team_count, + ROW_NUMBER() OVER (PARTITION BY ts.tournamentId ORDER BY ts.avg_team_ordinal DESC) as rank + FROM TeamSkills ts + JOIN TeamCounts tc ON tc.tournamentId = ts.tournamentId + WHERE ts.avg_team_ordinal IS NOT NULL + ), + TournamentScores AS ( + SELECT + tournamentId, + AVG(avg_team_ordinal) as top8_avg_ordinal, + MAX(team_count) as team_count + FROM RankedTeams + WHERE rank <= ${TOP_TEAMS_COUNT} + GROUP BY tournamentId + ) + SELECT + t.id as tournamentId, + COALESCE(ts.team_count, 0) as teamCount, + ts.top8_avg_ordinal as top8AvgOrdinal + FROM Tournament t + LEFT JOIN TournamentScores ts ON ts.tournamentId = t.id + WHERE t.isFinalized = 1 + `; + + return sql.prepare(query).all() as TournamentScore[]; +} + +function getTournamentsWithOrg(): TournamentWithOrg[] { + const query = /* sql */ ` + SELECT + t.id as tournamentId, + ce.name, + ce.organizationId, + ced.startTime + FROM Tournament t + INNER JOIN CalendarEvent ce ON ce.tournamentId = t.id + INNER JOIN CalendarEventDate ced ON ced.eventId = ce.id + WHERE t.isFinalized = 1 + ORDER BY ced.startTime ASC + `; + return sql.prepare(query).all() as TournamentWithOrg[]; +} + +function getAllSeries(): Series[] { + const query = /* sql */ ` + SELECT id, organizationId, substringMatches + FROM TournamentOrganizationSeries + `; + return sql.prepare(query).all() as Series[]; +} + +function matchesSubstring( + eventName: string, + substringMatches: string[], +): boolean { + const eventNameLower = eventName.toLowerCase(); + return substringMatches.some((match) => + eventNameLower.includes(match.toLowerCase()), + ); +} + +function main() { + console.log("=== Backfilling Tournament Tiers ===\n"); + if (dryRun) { + console.log("DRY RUN - no changes will be made\n"); + } + + const tournaments = getTournamentScores(); + console.log(`Found ${tournaments.length} finalized tournaments\n`); + + const updateTierStatement = sql.prepare( + /* sql */ `UPDATE "Tournament" SET tier = @tier WHERE id = @tournamentId`, + ); + + const tierCounts: Record = {}; + const tournamentTiers = new Map(); + let updatedCount = 0; + let skippedCount = 0; + + for (const t of tournaments) { + const meetsMinTeams = t.teamCount >= MIN_TEAMS_FOR_TIERING; + + let tierNumber: number | null = null; + if (t.top8AvgOrdinal !== null && meetsMinTeams) { + const adjustedScore = calculateAdjustedScore( + t.top8AvgOrdinal, + t.teamCount, + ); + tierNumber = calculateTierNumber(adjustedScore); + } + + if (tierNumber !== null) { + tierCounts[tierNumber] = (tierCounts[tierNumber] || 0) + 1; + tournamentTiers.set(t.tournamentId, tierNumber); + updatedCount++; + } else { + skippedCount++; + } + + if (!dryRun) { + updateTierStatement.run({ + tier: tierNumber, + tournamentId: t.tournamentId, + }); + } + } + + console.log("Tier distribution:"); + const tierNames: Record = { + 1: "X", + 2: "S+", + 3: "S", + 4: "A+", + 5: "A", + 6: "B+", + 7: "B", + 8: "C+", + 9: "C", + }; + for (let i = 1; i <= 9; i++) { + console.log(` ${tierNames[i]}: ${tierCounts[i] || 0}`); + } + + console.log(`\nUpdated: ${updatedCount} tournaments`); + console.log(`Skipped (untiered): ${skippedCount} tournaments`); + + console.log("\n=== Backfilling Series Tier History ===\n"); + + const allSeries = getAllSeries(); + const tournamentsWithOrg = getTournamentsWithOrg(); + console.log(`Found ${allSeries.length} series`); + console.log( + `Found ${tournamentsWithOrg.filter((t) => t.organizationId !== null).length} tournaments with organizations\n`, + ); + + const updateSeriesStatement = sql.prepare( + /* sql */ "UPDATE TournamentOrganizationSeries SET tierHistory = @tierHistory WHERE id = @seriesId", + ); + + const seriesByOrg = new Map(); + for (const series of allSeries) { + const existing = seriesByOrg.get(series.organizationId) ?? []; + existing.push(series); + seriesByOrg.set(series.organizationId, existing); + } + + let seriesUpdatedCount = 0; + let seriesSkippedCount = 0; + + for (const [organizationId, orgSeries] of seriesByOrg.entries()) { + const orgTournaments = tournamentsWithOrg.filter( + (t) => t.organizationId === organizationId, + ); + + for (const series of orgSeries) { + const substringMatches = JSON.parse(series.substringMatches) as string[]; + const matchingTournaments = orgTournaments + .filter((t) => matchesSubstring(t.name, substringMatches)) + .filter((t) => tournamentTiers.has(t.tournamentId)); + + if (matchingTournaments.length === 0) { + seriesSkippedCount++; + continue; + } + + const tierHistory = matchingTournaments + .slice(-TIER_HISTORY_LENGTH) + .map((t) => tournamentTiers.get(t.tournamentId)!); + + console.log( + `Series ${series.id} (org ${organizationId}): ${matchingTournaments.length} matching tournaments, tierHistory = [${tierHistory.join(", ")}]`, + ); + + if (!dryRun) { + updateSeriesStatement.run({ + seriesId: series.id, + tierHistory: JSON.stringify(tierHistory), + }); + } + seriesUpdatedCount++; + } + } + + console.log(`\nSeries updated: ${seriesUpdatedCount}`); + console.log( + `Series skipped (no matching tournaments): ${seriesSkippedCount}`, + ); + + if (dryRun) { + console.log("\nRun without --dry-run to apply changes"); + } +} + +main(); diff --git a/scripts/tournament-tiers-experiment.ts b/scripts/tournament-tiers-experiment.ts new file mode 100644 index 000000000..324bd459c --- /dev/null +++ b/scripts/tournament-tiers-experiment.ts @@ -0,0 +1,406 @@ +/** biome-ignore-all lint/suspicious/noConsole: CLI script output */ +/** + * Tournament Tiering Experiment Script + * + * Run with: npx tsx scripts/tournament-tiers-experiment.ts + * + * Calculates tournament tiers based on top teams' average SeedingSkill. + * Tweak the THRESHOLDS object to experiment with different tier distributions. + */ + +import Database from "better-sqlite3"; + +const db = new Database("db-prod.sqlite3", { readonly: true }); + +// ============================================================================ +// CONFIGURATION - Tweak these values to experiment +// ============================================================================ + +/** + * Minimum ordinal thresholds for each tier. + * A tournament is assigned the highest tier where its score meets the threshold. + * + * Current values are based on percentile analysis: + * - X: Top ~1% of tournaments + * - S+: Top ~3% + * - S: Top ~8% + * - A+: Top ~15% + * - A: Top ~25% + * - B+: Top ~40% + * - B: Top ~55% + * - C+: Top ~75% + * - C: Everything else + */ +const THRESHOLDS = { + X: 32, + "S+": 29, + S: 26, + "A+": 24, + A: 21, + "B+": 15, + B: 10, + "C+": 5, + C: Number.NEGATIVE_INFINITY, // Catch-all +} as const; + +/** + * How many top teams to consider for the tournament score. + * Using 8 captures the competitive core of most tournaments. + */ +const TOP_TEAMS_COUNT = 8; + +/** + * Minimum number of teams required for a tournament to be tiered. + * Tournaments with fewer teams will be marked as "UNTIERED". + */ +const MIN_TEAMS_FOR_TIERING = 8; + +/** + * Size bonus configuration. + * The bonus scales inversely with skill level - X-tier gets no bonus, + * lower tiers get increasingly more bonus for larger tournaments. + * + * NO_BONUS_ABOVE: Score threshold above which no size bonus applies (X-tier) + * MAX_BONUS_PER_10_TEAMS: Maximum bonus per 10 teams above minimum (applied at score 0) + * + * The bonus scales linearly: at NO_BONUS_ABOVE, multiplier is 0. + * As score decreases toward 0, multiplier approaches MAX_BONUS_PER_10_TEAMS. + * + * Formula: bonus = scaleFactor * MAX_BONUS * (teamsAboveMin / 10) + * where scaleFactor = max(0, (NO_BONUS_ABOVE - rawScore) / NO_BONUS_ABOVE) + */ +const SIZE_BONUS = { + NO_BONUS_ABOVE: 32, // X-tier threshold - no bonus at this level + MAX_BONUS_PER_10_TEAMS: 1.5, // Max points added per 10 extra teams (at score ~0) +}; + +/** + * Filter to only include tournaments after this date (ISO string). + * Set to null to include all tournaments. + */ +const MIN_DATE: string | null = null; // e.g., "2024-01-01" + +// ============================================================================ +// IMPLEMENTATION +// ============================================================================ + +type Tier = keyof typeof THRESHOLDS; + +interface TournamentData { + tournamentId: number; + eventId: number; + name: string; + startTime: number; + teamCount: number; + top8AvgOrdinal: number | null; + adjustedScore: number | null; + tier: Tier | "UNTIERED"; +} + +function calculateTier(score: number | null): Tier | "UNTIERED" { + if (score === null) return "UNTIERED"; + + const tiers = Object.entries(THRESHOLDS) as [Tier, number][]; + for (const [tier, threshold] of tiers) { + if (score >= threshold) return tier; + } + return "C"; +} + +function calculateAdjustedScore(rawScore: number, teamCount: number): number { + if (SIZE_BONUS.MAX_BONUS_PER_10_TEAMS === 0) return rawScore; + + // Scale factor: 0 at NO_BONUS_ABOVE, approaches 1 as score approaches 0 + const scaleFactor = Math.max( + 0, + (SIZE_BONUS.NO_BONUS_ABOVE - rawScore) / SIZE_BONUS.NO_BONUS_ABOVE, + ); + + const teamsAboveMin = Math.max(0, teamCount - MIN_TEAMS_FOR_TIERING); + const bonus = + scaleFactor * SIZE_BONUS.MAX_BONUS_PER_10_TEAMS * (teamsAboveMin / 10); + + return rawScore + bonus; +} + +function getTournamentData(): TournamentData[] { + const query = ` + WITH TeamSkills AS ( + SELECT + tt.tournamentId, + tt.id as teamId, + AVG(ss.ordinal) as avg_team_ordinal + FROM TournamentTeam tt + JOIN TournamentTeamMember ttm ON ttm.tournamentTeamId = tt.id + LEFT JOIN SeedingSkill ss ON ss.userId = ttm.userId AND ss.type = 'RANKED' + WHERE tt.droppedOut = 0 + GROUP BY tt.tournamentId, tt.id + ), + TeamCounts AS ( + SELECT tournamentId, COUNT(*) as team_count + FROM TeamSkills + WHERE avg_team_ordinal IS NOT NULL + GROUP BY tournamentId + ), + RankedTeams AS ( + SELECT + ts.tournamentId, + ts.avg_team_ordinal, + tc.team_count, + ROW_NUMBER() OVER (PARTITION BY ts.tournamentId ORDER BY ts.avg_team_ordinal DESC) as rank + FROM TeamSkills ts + JOIN TeamCounts tc ON tc.tournamentId = ts.tournamentId + WHERE ts.avg_team_ordinal IS NOT NULL + ), + TournamentScores AS ( + SELECT + tournamentId, + AVG(avg_team_ordinal) as top8_avg_ordinal, + MAX(team_count) as team_count + FROM RankedTeams + WHERE rank <= ${TOP_TEAMS_COUNT} + GROUP BY tournamentId + ) + SELECT + t.id as tournamentId, + ce.id as eventId, + ce.name, + ced.startTime, + ts.team_count as teamCount, + ts.top8_avg_ordinal as top8AvgOrdinal + FROM Tournament t + JOIN CalendarEvent ce ON ce.tournamentId = t.id + JOIN CalendarEventDate ced ON ced.eventId = ce.id + LEFT JOIN TournamentScores ts ON ts.tournamentId = t.id + WHERE t.isFinalized = 1 + ${MIN_DATE ? `AND ced.startTime >= strftime('%s', '${MIN_DATE}') * 1000` : ""} + GROUP BY t.id + ORDER BY ced.startTime DESC + `; + + const rows = db.prepare(query).all() as Array<{ + tournamentId: number; + eventId: number; + name: string; + startTime: number; + teamCount: number | null; + top8AvgOrdinal: number | null; + }>; + + return rows.map((row) => { + const teamCount = row.teamCount ?? 0; + const meetsMinTeams = teamCount >= MIN_TEAMS_FOR_TIERING; + + let adjustedScore: number | null = null; + if (row.top8AvgOrdinal !== null && meetsMinTeams) { + adjustedScore = calculateAdjustedScore(row.top8AvgOrdinal, teamCount); + } + + return { + tournamentId: row.tournamentId, + eventId: row.eventId, + name: row.name, + startTime: row.startTime, + teamCount, + top8AvgOrdinal: row.top8AvgOrdinal, + adjustedScore, + tier: meetsMinTeams ? calculateTier(adjustedScore) : "UNTIERED", + }; + }); +} + +function printDistribution(tournaments: TournamentData[]) { + const distribution: Record = {}; + const tiers = [...Object.keys(THRESHOLDS), "UNTIERED"]; + + for (const tier of tiers) { + distribution[tier] = 0; + } + + for (const t of tournaments) { + distribution[t.tier]++; + } + + const total = tournaments.length; + const tiered = total - distribution.UNTIERED; + + console.log(`\n${"=".repeat(60)}`); + console.log("TIER DISTRIBUTION"); + console.log("=".repeat(60)); + console.log(`Total tournaments: ${total}`); + console.log(`Tiered (${MIN_TEAMS_FOR_TIERING}+ teams): ${tiered}`); + console.log( + `Untiered (< ${MIN_TEAMS_FOR_TIERING} teams): ${distribution.UNTIERED}`, + ); + console.log("-".repeat(60)); + + for (const tier of tiers) { + if (tier === "UNTIERED") continue; + const count = distribution[tier]; + const pctOfTiered = tiered > 0 ? ((count / tiered) * 100).toFixed(1) : "0"; + const bar = "█".repeat(Math.round(count / 20)); + console.log( + `${tier.padEnd(3)} | ${String(count).padStart(4)} | ${pctOfTiered.padStart(5)}% of tiered | ${bar}`, + ); + } +} + +function formatDate(timestamp: number): string { + // timestamps are stored in seconds, not milliseconds + return new Date(timestamp * 1000).toISOString().split("T")[0]; +} + +function printTopTournaments( + tournaments: TournamentData[], + tier: Tier, + limit = 10, +) { + const filtered = tournaments + .filter((t) => t.tier === tier) + .sort((a, b) => (b.adjustedScore ?? 0) - (a.adjustedScore ?? 0)) + .slice(0, limit); + + console.log(`\n${"=".repeat(60)}`); + console.log(`TOP ${limit} ${tier}-TIER TOURNAMENTS`); + console.log("=".repeat(60)); + + for (const t of filtered) { + const date = formatDate(t.startTime); + console.log( + `[${date}] ${t.name.substring(0, 40).padEnd(40)} | ${t.teamCount} teams | score: ${t.adjustedScore?.toFixed(1)}`, + ); + } +} + +function printBottomOfTier( + tournaments: TournamentData[], + tier: Tier, + limit = 5, +) { + const filtered = tournaments + .filter((t) => t.tier === tier) + .sort((a, b) => (a.adjustedScore ?? 0) - (b.adjustedScore ?? 0)) + .slice(0, limit); + + console.log(`\n${"-".repeat(60)}`); + console.log(`BOTTOM ${limit} OF ${tier}-TIER (borderline)`); + console.log("-".repeat(60)); + + for (const t of filtered) { + const date = formatDate(t.startTime); + console.log( + `[${date}] ${t.name.substring(0, 40).padEnd(40)} | ${t.teamCount} teams | score: ${t.adjustedScore?.toFixed(1)}`, + ); + } +} + +function printThresholds() { + console.log(`\n${"=".repeat(60)}`); + console.log("CURRENT THRESHOLDS"); + console.log("=".repeat(60)); + for (const [tier, threshold] of Object.entries(THRESHOLDS)) { + if (threshold === Number.NEGATIVE_INFINITY) { + console.log(`${tier}: < ${THRESHOLDS["C+"]}`); + } else { + console.log(`${tier}: >= ${threshold}`); + } + } + console.log(`\nTop teams considered: ${TOP_TEAMS_COUNT}`); + console.log(`Min teams for tiering: ${MIN_TEAMS_FOR_TIERING}`); + console.log("\nSize bonus (scales inversely with skill):"); + console.log(` No bonus above score: ${SIZE_BONUS.NO_BONUS_ABOVE}`); + console.log( + ` Max bonus per 10 teams: ${SIZE_BONUS.MAX_BONUS_PER_10_TEAMS} points`, + ); + + // Show example bonus calculations + console.log("\nExample bonuses for 50-team tournament:"); + const exampleTeams = 50; + for (const rawScore of [32, 28, 24, 20, 15, 10, 5, 0]) { + const adjusted = calculateAdjustedScore(rawScore, exampleTeams); + const bonus = adjusted - rawScore; + console.log( + ` Raw ${rawScore.toString().padStart(2)} -> ${adjusted.toFixed(2)} (+${bonus.toFixed(2)})`, + ); + } +} + +// ============================================================================ +// MAIN +// ============================================================================ + +function main() { + console.log("Tournament Tiering Experiment"); + console.log("==============================\n"); + + printThresholds(); + + const tournaments = getTournamentData(); + printDistribution(tournaments); + + // Show examples from top tiers + printTopTournaments(tournaments, "X", 15); + printBottomOfTier(tournaments, "X", 5); + + printTopTournaments(tournaments, "S+", 10); + printBottomOfTier(tournaments, "S+", 5); + + printTopTournaments(tournaments, "S", 10); + + // Show tournaments promoted by size bonus + console.log(`\n${"=".repeat(60)}`); + console.log("TOURNAMENTS PROMOTED BY SIZE BONUS"); + console.log("=".repeat(60)); + const promoted = tournaments + .filter((t) => t.tier !== "UNTIERED" && t.top8AvgOrdinal !== null) + .filter((t) => { + const rawTier = calculateTier(t.top8AvgOrdinal); + return rawTier !== t.tier; + }) + .sort((a, b) => (b.adjustedScore ?? 0) - (a.adjustedScore ?? 0)) + .slice(0, 20); + + if (promoted.length === 0) { + console.log("No tournaments were promoted by size bonus."); + } else { + for (const t of promoted) { + const rawTier = calculateTier(t.top8AvgOrdinal); + const bonus = (t.adjustedScore ?? 0) - (t.top8AvgOrdinal ?? 0); + console.log( + `${rawTier.toString().padEnd(3)} -> ${t.tier.padEnd(3)} | ${t.name.substring(0, 35).padEnd(35)} | ${t.teamCount} teams | +${bonus.toFixed(2)}`, + ); + } + } + + // Recent tournaments analysis + console.log(`\n${"=".repeat(60)}`); + console.log("RECENT TOURNAMENTS (last 30 tiered)"); + console.log("=".repeat(60)); + tournaments + .filter((t) => t.tier !== "UNTIERED") + .sort((a, b) => b.startTime - a.startTime) + .slice(0, 30) + .forEach((t) => { + const date = formatDate(t.startTime); + const safeName = t.name.substring(0, 35).padEnd(35); + console.log( + `${t.tier.padEnd(3)} | [${date}] ${safeName} | ${String(t.teamCount).padStart(3)} teams | ${t.adjustedScore?.toFixed(1)}`, + ); + }); + + // Full CSV dump ordered by score + console.log(`\n${"=".repeat(60)}`); + console.log("FULL CSV DUMP (ordered by score descending)"); + console.log("=".repeat(60)); + console.log("name,score,tier"); + tournaments + .filter((t) => t.tier !== "UNTIERED") + .sort((a, b) => (b.adjustedScore ?? 0) - (a.adjustedScore ?? 0)) + .forEach((t) => { + const safeName = t.name.replace(/,/g, ";").replace(/"/g, "'"); + console.log(`"${safeName}",${t.adjustedScore?.toFixed(2)},${t.tier}`); + }); +} + +main(); +db.close(); diff --git a/tournament-tiers.csv b/tournament-tiers.csv new file mode 100644 index 000000000..41c151330 --- /dev/null +++ b/tournament-tiers.csv @@ -0,0 +1,1710 @@ +name,score,tier +"fry basket #12 ! ",36.14,X +"Fry Basket + h20 #13 We Don't Care",35.72,X +"SendouQ Season 8 Finale",35.25,X +"SendouQ Season 7 Finale",35.25,X +"Fry basket + h20 #14 Happy birthday Henlo & Thunder",35.01,X +"In The Zone 38",34.79,X +"4v4 Sundaes: Homecoming 2",34.74,X +"SendouQ Season 4 Finale",34.58,X +"In The Zone 39",34.50,X +"THE GLAZE GAUNTLET 5",34.47,X +"PICNIC 7",34.42,X +"In The Zone 43",34.37,X +"Black Lotus Cup 3 🏆",34.34,X +"In The Zone 45",34.29,X +"In The Zone 42",34.25,X +"Splat World Series - Finals",34.22,X +"Close & Personal 6",34.19,X +"Black Lotus Cup 2 🏆",34.15,X +"In The Zone 44",33.98,X +"Splat'n'Go by eSportBrosTV : Anniversary Edition",33.79,X +"SendouQ Season 6 Finale",33.62,X +"Fry Basket #15 Mel0mania edition",33.62,X +"In The Zone 40",33.62,X +"Fry Basket #10 Warning Shots",33.54,X +"LUTI: Season 16 - Division X",33.48,X +"In The Zone 48",33.35,X +"In The Zone 34",33.32,X +"Squid Junction 100",33.17,X +"Barnacle Bash #6.5",33.17,X +"Fry Basket #17 Anniversary",33.11,X +"SendouQ Season 9 Finale",33.05,X +"In The Zone 36",33.04,X +"In The Zone 41",33.00,X +"Fry Basket Invitational #1",32.97,X +"Fry basket #11 Pou ",32.92,X +"Barnacle Bash #9",32.89,X +"4v4 Sundaes: Homecoming",32.88,X +"4v4 Sundaes on a Saturday",32.87,X +"Triton-Cup 101",32.80,X +"Barnacle Bash 3",32.78,X +"Barnacle Bash 5",32.76,X +"Triton-Cup 100",32.47,X +"In The Zone 35",32.39,X +"Triton-Cup 104",32.30,X +"A Special Tournament",32.28,X +"Triton-Cup 110",32.22,X +"THE GLAZE GAUNTLET 4",32.16,X +"SendouQ Season 2 Finale",32.07,X +"Barnacle Bash 4",31.99,S+ +"THE GLAZE GAUNTLET 3",31.93,S+ +"SendouQ Season 5 Finale",31.90,S+ +"Fry Basket #16 Badge Palooza",31.87,S+ +"In The Zone 47",31.82,S+ +"In The Zone 46",31.70,S+ +"In The Zone 31",31.55,S+ +"In The Zone 32",31.54,S+ +"In The Zone 37",31.45,S+ +"20XX League 1st Qualifier",31.45,S+ +"SendouQ Season 3 Finale",31.44,S+ +"No Bazooka",31.42,S+ +"FREEDOM DiVE ↓",31.33,S+ +"FLUTI 6",31.26,S+ +"Proving Grounds 29",31.23,S+ +"Golden Paddling Pool 300",31.22,S+ +"Mode Cup #11 Splat Zones Only!",31.06,S+ +"Barnacle Bash #8",31.02,S+ +"Megalodon Monday NA 28 (Money Prizes)",30.99,S+ +"Barnacle Bash #7",30.96,S+ +"In The Zone 33",30.96,S+ +"Triton-Cup 121",30.91,S+ +"Triton-Cup 122",30.90,S+ +"Triton-Cup 92",30.88,S+ +"Pinch Zach’s Bank #4",30.85,S+ +"Champions Cup #1 Splat Zones Edition",30.85,S+ +"GOLDEN Paddling Pool 340",30.85,S+ +"Gamers for Giving - Splatoon 3 Charity Tournament",30.77,S+ +"Triton-Cup 108",30.73,S+ +"Triton-Cup 98",30.68,S+ +"Black Lotus Cup 🏆",30.58,S+ +"Triton-Cup 123",30.58,S+ +"Triton-Cup 102",30.56,S+ +"PICNIC 5",30.49,S+ +"SQUIDS FOR PALESTINIAN KIDS 2",30.49,S+ +"Triton-Cup 109",30.46,S+ +"Paddling Pool 327",30.46,S+ +"In The Zone 29",30.43,S+ +"Heaven 2 Ocean S3 Qual 1",30.42,S+ +"Hardcore Mode #5 (One Shot)",30.36,S+ +"PICNIC 6",30.25,S+ +"Megalodon Monday NA 34",30.23,S+ +"Triton-Cup 106",30.21,S+ +"Triton-Cup 115",30.21,S+ +"SQUIDS FOR PALESTINIAN KIDS 3: Third Course",30.12,S+ +"Triton-Cup 99",30.10,S+ +"Swim or Sink 178",29.96,S+ +"MIKU MIKU BEAM!!! 30 (Grand Stage)",29.96,S+ +"GOLDEN Paddling Pool 310",29.95,S+ +"GOLDEN Paddling Pool 280",29.90,S+ +"Triton-Cup 82",29.89,S+ +"Swim or Sink 166",29.88,S+ +"Triton-Cup 90",29.85,S+ +"SendouQ Season 1 Finale",29.84,S+ +"Triton-Cup 89",29.83,S+ +"Paddling Pool 324",29.82,S+ +"THE GLAZE GAUNTLET",29.77,S+ +"Triton-Cup 107",29.76,S+ +"Champions Cup #0 ",29.72,S+ +"Swim or Sink 157",29.71,S+ +"Gimmick Series: Level Up 2",29.71,S+ +"Solar Eclipse 20",29.66,S+ +"20XX League Last Chance Qualifier",29.65,S+ +"Triton-Cup 105",29.65,S+ +"Swim or Sink 158",29.62,S+ +"Swim or Sink 195",29.56,S+ +"Paddling Pool 279",29.48,S+ +"Paddling Pool 330",29.45,S+ +"GOLDEN Paddling Pool 320",29.44,S+ +"20XX League 2nd Qualifier",29.44,S+ +"Triton-Cup 86",29.43,S+ +"Paddling Pool 329",29.40,S+ +"Swim or Sink 153",29.35,S+ +"Triton-Cup 97",29.34,S+ +"In The Zone 30",29.32,S+ +"Paddling Pool 321",29.32,S+ +"Paddling Pool 333",29.29,S+ +"Triton-Cup 128",29.23,S+ +"GLAZE GAUNTLET 2",29.19,S+ +"GOLDEN Paddling Pool 290",29.10,S+ +"Paddling Pool 339",29.10,S+ +"Baja's Blast #25",29.09,S+ +"Swim or Sink 200!",29.08,S+ +"In The Zone 28",29.03,S+ +"Swim or Sink 179",28.99,S +"Megalodon Monday NA 33",28.98,S +"Paddling Pool 345",28.98,S +"988 Charity Cup",28.97,S +"Triton-Cup 120",28.93,S +"Proving Grounds 36",28.93,S +"Triton-Cup 103",28.90,S +"Triton-Cup 85",28.90,S +"Triton-Cup 83",28.90,S +"Kraken Royale 18: Zones Royale",28.90,S +"PICNIC #3",28.87,S +"Paddling Pool 273",28.87,S +"Swim or Sink 198",28.81,S +"Swim or Sink 183",28.80,S +"Hardcore Mode #7",28.79,S +"Triton-Cup 72",28.78,S +"Paddling Pool 306",28.75,S +"Close & Personal 9",28.74,S +"Swim or Sink 163",28.72,S +"Swim or Sink 210",28.72,S +"Triton-Cup 87",28.70,S +"Paddling Pool 270",28.65,S +"Triton-Cup 127",28.65,S +"PICNIC 4",28.63,S +"Battle 4 BIPOC II!",28.63,S +"Squid Junction 93",28.62,S +"Paddling Pool 316",28.57,S +"MIKU MIKU BEAM!!! #4",28.56,S +"Triton-Cup 79",28.56,S +"Swim or Sink 196",28.54,S +"Paddling Pool 276",28.52,S +"Lego Fortnite Cash Cup",28.51,S +"Triton-Cup 113",28.50,S +"SoulSilver Cup #1",28.45,S +"MIKU MIKU BEAM!!! #15",28.39,S +"Triton-Cup 95",28.38,S +"Paddling Pool 315",28.37,S +"Christmas GOLDEN Paddling Pool 350",28.37,S +"Dual Ink #199",28.35,S +"Swim or Sink 188",28.35,S +"Swim or Sink 194",28.35,S +"Swim or Sink 187",28.35,S +"Sizzle '24 Seasonal Showdown",28.33,S +"Hardcore Mode #2",28.33,S +"Triton-Cup 124",28.31,S +"Swim or Sink 203",28.31,S +"Triton-Cup 116",28.30,S +"Megalodon Monday NA 32",28.26,S +"Hardcore Mode",28.25,S +"Close & Personal 7",28.24,S +"Swim or Sink 191",28.21,S +"Swim or Sink 154",28.21,S +"Swim or Sink 156",28.18,S +"Paddling Pool 294",28.16,S +"Swim or Sink 159",28.13,S +"Megalodon Monday NA 17 (Ezios Final)",28.11,S +"Swim or Sink 193",28.09,S +"Swim or Sink 155",28.07,S +"Paddling Pool 323",28.06,S +"Paddling Pool 303",28.05,S +"Triton-Cup 117",28.05,S +"Get Coupled 7",28.04,S +"Swim or Sink 211",28.00,S +"Triton-Cup 111",28.00,S +"Swim or Sink 197",27.99,S +"Paddling Pool 278",27.97,S +"Paddling Pool 304",27.97,S +"Swim or Sink 189",27.96,S +"Swim or Sink 177",27.94,S +"Swim or Sink 142",27.94,S +"Triton-Cup 73",27.91,S +"Baja's Blast #29",27.84,S +"Triton-Cup 130",27.84,S +"Triton-Cup 78",27.83,S +"Fry Basket #3: Double Dine",27.81,S +"Paddling Pool 328",27.81,S +"Swim or Sink 184",27.79,S +"Squid Junction 77",27.76,S +"Swim or Sink 192",27.76,S +"Triton-Cup 91",27.75,S +"Paddling Pool 314",27.75,S +"Paddling Pool 295",27.74,S +"Gimmick Series: Coffee Date 4",27.71,S +"8-Bit Blitz #36",27.69,S +"Megalodon Monday NA 18",27.68,S +"Swim or Sink 180",27.68,S +"Triton-Cup 77",27.66,S +"Breakpoint #0",27.66,S +"Crobattle Royale: Mel0's Birthday Edition (28th)",27.64,S +"Shark Tank: Deep Waters #11",27.61,S +"Hardcore Mode #4",27.59,S +"Swim or Sink 162",27.59,S +"Paddling Pool 311",27.55,S +"Swim or Sink 186",27.54,S +"Triton-Cup 84",27.54,S +"Paddling Pool 305",27.53,S +"Fry Basket #4",27.51,S +"Baja's Blast #1: SZ",27.49,S +"Swim or Sink 202",27.47,S +"Ride the Wave 0",27.47,S +"Swim or Sink 167",27.46,S +"Fry Basket #6 Sampler",27.45,S +"Crobattle Royale: 16th Edition",27.44,S +"Swim or Sink 213",27.42,S +"Triton-Cup 74",27.42,S +"Baja's Blast #26",27.40,S +"Dual Ink #201",27.40,S +"Baja's Blast #10: SZ",27.39,S +"Paddling Pool 275",27.38,S +"Swim or Sink 212",27.36,S +"Paddling Pool 260",27.36,S +"Swim or Sink 173",27.33,S +"Dual Ink #220",27.33,S +"Swim or Sink 208",27.30,S +"Squid Junction 99",27.29,S +"Paddling Pool 319",27.28,S +"Dual Ink #200",27.26,S +"LUTI: Season 16 - Division 1",27.23,S +"Close & Personal 5",27.22,S +"Triton-Cup 81",27.20,S +"Megalodon Monday Finale",27.16,S +"Swim or Sink 190",27.15,S +"Swim or Sink 165",27.13,S +"Paddling Pool 256",27.13,S +"Swim or Sink 209",27.13,S +"Squid Junction 87",27.13,S +"MIKU MIKU BEAM!!! #2",27.12,S +"Swim or Sink 150",27.12,S +"Shark Tank: Deep Waters #19",27.08,S +"Swim or Sink 149",27.05,S +"Triton-Cup 88",27.05,S +"Paddling Pool 307",27.04,S +"Swim or Sink 143",27.03,S +"Paddling Pool 292",27.02,S +"Swim or Sink 172",27.00,S +"Dolphin Showdown 17",26.99,S +"Paddling Pool 325",26.98,S +"Triton-Cup 129",26.96,S +"Paddling Pool 291",26.94,S +"Swim or Sink 161",26.94,S +"Chromatic Co-op Week 53: NA Edition",26.92,S +"Hardcore Mode #8",26.92,S +"Proving Grounds 30",26.89,S +"Level Up 28: Token Run",26.88,S +"Paddling Pool 332",26.87,S +"MIKU MIKU BEAM!!! #10 (Grand Stage)",26.87,S +"Triton-Cup 76",26.86,S +"Chromatic Co-op Week 32: EU Edition",26.85,S +"Paddling Pool 269",26.83,S +"Triton-Cup 96",26.83,S +"Paddling Pool 272",26.82,S +"Squid Junction 75",26.81,S +"Pinch Zach’s Bank #3",26.80,S +"Paddling Pool 308",26.79,S +"Baja's Blast #31",26.79,S +"Break Zach’s Bank #11: THE RETURN (ALL MODES)",26.79,S +"Level Up 59",26.78,S +"Paddling Pool 344",26.78,S +"MIKU MIKU BEAM!!! 25",26.78,S +"Unnamed Tournament - Last Ditch Effort",26.77,S +"Paddling Pool 347",26.73,S +"Paddling Pool 282",26.70,S +"Paddling Pool 312",26.69,S +"Proving Grounds 31",26.69,S +"Crobattle Royale: 34th Edition",26.68,S +"Paddling Pool 326",26.66,S +"Sainsbury's Scrap #2",26.66,S +"Paddling Pool 266",26.60,S +"Champions Cup #4",26.57,S +"Level Up 53",26.57,S +"MIKU MIKU BEAM!!! #20 Grand Stage",26.54,S +"Squid Junction 74",26.53,S +"Swim or Sink 160",26.52,S +"Baja's Blast #32",26.51,S +"Chromatic Co-op Week 50: NA edition",26.50,S +"Swim or Sink 185",26.46,S +"GOLDEN Tropical Tuesdays 25 ($100 Starting Prize Pool)",26.44,S +"Swim or Sink 207",26.40,S +"Chromatic Co-op Week 37: NA Edition",26.37,S +"Swim or Sink 141",26.36,S +"Paddling Pool 265",26.35,S +"Swim or Sink 148",26.34,S +"Swim or Sink 201",26.34,S +"Shark Tank: Deep Waters #8",26.34,S +"Carma's Colosseum",26.33,S +"Triton-Cup 93",26.33,S +"PICNIC mini",26.31,S +"1v1 #2",26.30,S +"Triton-Cup 118",26.29,S +"Swim or Sink 151",26.27,S +"Paddling Pool 277",26.27,S +"Swim or Sink 182",26.26,S +"Dual Ink #229",26.25,S +"Megalodon Monday NA 30",26.25,S +"Crimson INK GOLD",26.23,S +"Squid Junction 76",26.22,S +"Swim or Sink 176",26.22,S +"Megalodon Monday EU 18",26.21,S +"Triton-Cup 80",26.21,S +"Swim or Sink 140",26.19,S +"2v2 TUESDAYS #38",26.15,S +"Paddling Pool 281",26.14,S +"MEGALADON MIKU MONDAY!!! #19",26.14,S +"Squid Junction 81",26.13,S +"Proxy Training #1",26.12,S +"Baja's Blast #23",26.11,S +"Proving Grounds 32",26.10,S +"Shark Tank: Deep Waters #15",26.09,S +"Paddling Pool 261",26.09,S +"Triton-Cup 114",26.08,S +"Paddling Pool 288",26.08,S +"Paddling Pool 298",26.06,S +"Paddling Pool 286",26.05,S +"Megalodon Monday EU 4",26.05,S +"Chromatic Co-op Week 31: NA Edition",26.02,S +"Proving Grounds 35",26.02,S +"Paddling Pool 331",26.01,S +"The Jauntlet 4: Neo Conquest",26.00,A+ +"Swim or Sink 169",25.99,A+ +"Squid Junction 97",25.98,A+ +"Paddling Pool 301",25.94,A+ +"Dolphin Showdown NA 12",25.93,A+ +"Megalodon Monday EU 3",25.92,A+ +"Paddling Pool 343",25.91,A+ +"Triton-Cup 75",25.90,A+ +"Paddling Pool 317",25.89,A+ +"Pinch Zach’s Bank #5",25.87,A+ +"Dual Ink #230",25.87,A+ +"PICNIC #2",25.87,A+ +"Paddling Pool 322",25.87,A+ +"Crobattle Royale: 33rd Edition",25.86,A+ +"Triton-Cup 112",25.84,A+ +"Paddling Pool 253",25.84,A+ +"Paddling Pool 341",25.83,A+ +"Paddling Pool 318",25.82,A+ +"Paddling Pool 293",25.81,A+ +"Triton-Cup 125",25.80,A+ +"Paddling Pool 337",25.80,A+ +"Swim or Sink 152",25.78,A+ +"Paddling Pool 284",25.78,A+ +"Crobattle Royale: 13th Edition",25.77,A+ +"Paddling Pool 268",25.75,A+ +"Paddling Pool 351",25.75,A+ +"Triton-Cup 94",25.73,A+ +"Paddling Pool 334",25.73,A+ +"MIKU MIKU BEAM!!! 31",25.73,A+ +"Level Up 45: Shiny Hunt",25.73,A+ +"Baja's Blast #24",25.71,A+ +"Kraken Royale 15",25.71,A+ +"MIKU MIKU BEAM!!! 22",25.71,A+ +"Coral Clash: High Tide #28",25.70,A+ +"Paddling Pool 346",25.69,A+ +"Love Cup",25.62,A+ +"Paddling Pool 338",25.61,A+ +"Triton-Cup 70",25.60,A+ +"Swim or Sink 175",25.59,A+ +"Paddling Pool 271",25.59,A+ +"Champions Cup #2",25.59,A+ +"Swim or Sink 164",25.57,A+ +"MIKU MIKU BEAM!!! #18",25.55,A+ +"Fry Basket #2",25.54,A+ +"Paddling Pool 336",25.52,A+ +"Baja's Blast #22",25.52,A+ +"Paddling Pool 267",25.52,A+ +"The Jauntlet 3: JJaeigh's Jamboree",25.51,A+ +"Megalodon Monday NA 20",25.50,A+ +"Mesozoic Mayhem 18",25.50,A+ +"Swim or Sink 181",25.49,A+ +"MIKU MIKU BEAM!!! #5",25.49,A+ +"Swim or Sink 204",25.49,A+ +"Chromatic Co-op Week 35: NA Edition",25.46,A+ +"MIKU MIKU BEAM!!! 21",25.46,A+ +"Paddling Pool 297",25.44,A+ +"Break Zach's Bank #10: The Invitational",25.44,A+ +"Paddling Pool 257",25.43,A+ +"CROMIKU BATTLE!!! 23",25.42,A+ +"8-Bit Blitz #40",25.39,A+ +"MIKU MIKU BEAM!!! 29",25.39,A+ +"Triton-Cup 119",25.37,A+ +"Dual Ink #208",25.37,A+ +"Baja's Blast #15",25.36,A+ +"Swim or Sink 144",25.34,A+ +"MIKU MIKU BEAM!!!",25.34,A+ +"Heaven 2 Ocean S1 Qual 2",25.30,A+ +"Crobattle Royale: 29th Edition",25.28,A+ +"Hardcore Mode #3",25.28,A+ +"Crobattle Royale: 36th Edition",25.26,A+ +"Level Up 52",25.22,A+ +"Paddling Pool 289",25.21,A+ +"Squid Junction 88",25.21,A+ +"Paddling Pool 285",25.20,A+ +"Shark Tank: Deep Waters #25",25.20,A+ +"Crobattle Royale: 32nd Edition",25.16,A+ +"Paddling Pool 262",25.15,A+ +"Paddling Pool 296",25.15,A+ +"Neon Wail by SAC 14",25.14,A+ +"Neo Egoist League (FC Barcha)",25.13,A+ +"Paddling Pool 342",25.10,A+ +"Mesozoic Mayhem 22",25.09,A+ +"Paddling Pool 335",25.08,A+ +"The Jauntlet",25.08,A+ +"Close and Personal #16 Sundew Birthday Bash!",25.05,A+ +"Triton-Cup 126",25.04,A+ +"Swim or Sink 146",25.04,A+ +"Crobattle Royale: 22nd Edition",25.03,A+ +"Heaven 2 Ocean S2 FINALS",25.03,A+ +"Crobattle Royale: 12th Edition",25.02,A+ +"Pinch Zach’s Bank #1",25.00,A+ +"Megalodon Monday NA 31",24.99,A+ +"Paddling Pool 254",24.99,A+ +"Squid Junction 78",24.98,A+ +"Dolphin Showdown 21",24.97,A+ +"Dual Ink #202",24.96,A+ +"Paddling Pool 274",24.92,A+ +"Swim or Sink 168",24.92,A+ +"Swim or Sink 145",24.91,A+ +"Megalodon Monday EU 6",24.89,A+ +"Swim or Sink 206",24.86,A+ +"Triton-Cup 71",24.86,A+ +"Baja's Blast #17",24.85,A+ +"Dolphin Showdown 22",24.85,A+ +"67",24.85,A+ +"Chromatic Co-op Week 22",24.85,A+ +"MIKU MIKU BEAM!!! 32",24.80,A+ +"Shark Tank: Deep Waters #16",24.80,A+ +"Chromatic Co-Op Week 29: NA Edition",24.80,A+ +"Squid Junction 86",24.77,A+ +"Baja’s Blast #11",24.75,A+ +"Paddling Pool 251",24.75,A+ +"Crobattle Royale: 14th Edition",24.74,A+ +"Swim or Sink 170",24.71,A+ +"Prism Break 25",24.71,A+ +"Barracuda Co’s Barracuda Boon 8",24.69,A+ +"Mesozoic Mayhem 25",24.68,A+ +"Barnacle Bash #10",24.67,A+ +"Swim or Sink 205",24.67,A+ +"Chromatic Co-op Week 20",24.64,A+ +"Swim or Sink 199",24.63,A+ +"Level Up 33: Krissy birthday bash!",24.62,A+ +"Crobattle Royale: 26th Edition",24.59,A+ +"Level Up 39: Stara's Birthday Bash!",24.55,A+ +"Paddling Pool 349",24.55,A+ +"Megalodon Monday EU 5",24.54,A+ +"Crobattle Royale: 10th Edition",24.53,A+ +"Paddling Pool 352",24.52,A+ +"Paddling Pool 313",24.51,A+ +"Paddling Pool 309",24.51,A+ +"Level Up 19",24.49,A+ +"Lunar Eclipse 18",24.49,A+ +"Shark Tank: Deep Waters #20",24.48,A+ +"Lunar Eclipse 20",24.46,A+ +"Paddling Pool 255",24.44,A+ +"Solar Eclipse 16",24.42,A+ +"The Jauntlet 2: Auntie Edition",24.41,A+ +"Ranked Refreshments #4: Lemon",24.40,A+ +"Swim or Sink 174",24.35,A+ +"Shark Tank: Deep Waters #27",24.31,A+ +"Level Up 41",24.31,A+ +"Screw Shooters",24.26,A+ +"Level Up 43",24.26,A+ +"Paddling Pool 264",24.25,A+ +"Paddling Pool 283",24.22,A+ +"Dual Ink #207",24.22,A+ +"Paddling Pool 258",24.21,A+ +"Chromatic Co-op Week 36: EU Edition",24.20,A+ +"Swim or Sink 171",24.19,A+ +"Paddling Pool 348",24.19,A+ +"Level Up 50",24.19,A+ +"Close and Personal #13 Icy's Wave Goodbye",24.18,A+ +"MIKU MIKU BEAM!!! 24",24.17,A+ +"Level Up 51: Shiny Hunt",24.16,A+ +"Shark Tank: Deep Waters #14",24.16,A+ +"Prism Break 2",24.15,A+ +"Shark Tank: Deep Waters #12",24.14,A+ +"Crobattle Royale: 25th Edition",24.11,A+ +"Level Up 54: Shiny Hunt",24.11,A+ +"GHOST TYPE RUSH #21",24.11,A+ +"Shark Tank: Deep Waters #17",24.10,A+ +"Megalodon Monday NA 19",24.08,A+ +"Coral Clash: High Tide #27",24.08,A+ +"Swim or Sink 147",24.08,A+ +"Level Up 44",24.08,A+ +"Level Up 47",24.05,A+ +"K's Challenges #14: Out Of Bounds",24.03,A+ +"Megalodon Monday NA 35",24.01,A+ +"Level Up 42: Shiny Hunt",23.99,A +"Prism Break 1",23.99,A +"Chromatic Co-op Week 16",23.97,A +"Prism Break 18",23.97,A +"Kraken Royale 13",23.94,A +"Proving Grounds 33",23.92,A +"Mesozoic Mayhem 16",23.89,A +"Solar Eclipse 39",23.86,A +"Solar Eclipse 21",23.85,A +"Squid Junction 89",23.83,A +"Level Up 49",23.82,A +"Coral Clash: High Tide #23",23.80,A +"Shark Tank: Deep Waters #9",23.79,A +"Chromatic Co-op Week 24",23.76,A +"Baja’s Blast #13",23.72,A +"Lunar Eclipse 22",23.72,A +"Tropical Tuesdays 28",23.71,A +"SAC Season 9 Qualifier 1",23.71,A +"Level Up 26: Token Run",23.70,A +"MIKU MIKU BEAM!!! #8",23.69,A +"Megalodon Monday EU 9",23.68,A +"Chromatic Co-op Week 52: EU Edition",23.68,A +"Baja's Blast #28",23.68,A +"Level Up 31",23.67,A +"Neon Wail by SAC 16",23.66,A +"Baja's Blast #30",23.64,A +"Close & Personal 8",23.64,A +"Level Up 62",23.62,A +"Lunar Eclipse 43",23.59,A +"Kraken Royale 10",23.56,A +"Blue Pou Basket: Pousgiving",23.56,A +"MIKU MIKU BEAM!!! 27",23.55,A +"Tropical Tuesdays 29",23.49,A +"8-Bit Blitz #27",23.48,A +"Megalodon Monday EU 19",23.47,A +"SAC Season 9 Qualifier 3",23.47,A +"Megalodon Monday NA 25",23.45,A +"8-Bit Blitz #35",23.44,A +"Paddling Pool 287",23.44,A +"Shark Tank: Deep Waters #3",23.41,A +"Prism Break 10",23.41,A +"Urchin-Cup",23.41,A +"Megalodon Monday EU 2",23.40,A +"Chromatic Co-op Week 46: NA Edition",23.38,A +"Crimson INK",23.38,A +"Megalodon Monday EU 16",23.34,A +"GHOST TYPE RUSH NA EDITION",23.33,A +"Dolphin Showdown 13",23.29,A +"Birthdayzoic Mayhem 28",23.28,A +"Level Up 20: Token Run",23.27,A +"Baja's Blast #14",23.27,A +"Lunar Eclipse 28",23.26,A +"Baja’s Blast #12",23.24,A +"SAC Season 9 Qualifier 2",23.23,A +"Heaven 2 Ocean S1 FINALS",23.22,A +"Crobattle Royale: 27th Edition",23.21,A +"8-Bit Blitz #29",23.20,A +"Paddling Pool 259",23.20,A +"Crobattle Royale: 19th Edition",23.19,A +"Kraken Royale 12",23.17,A +"Megalodon Monday EU 17",23.17,A +"Crobattle Royale: 21st Edition",23.15,A +"Battle 4 BIPOC",23.15,A +"Prism Break: Metamorphosis (1)",23.14,A +"Megalodon Monday EU 7",23.12,A +"Dolphin Showdown 18",23.09,A +"SplaTea Cup #8",23.06,A +"Chromatic Co-op Week 25",23.06,A +"Neon Wail by SAC 13",23.05,A +"Baja's Blast #8: SZ",23.05,A +"Close and Personal #14",23.04,A +"Tropical Tuesdays 21",23.03,A +"Crobattle Royale: 11th Edition",23.01,A +"MIKU MIKU BEAM!!! 26",23.00,A +"LUTI: Season 16 - Division 2",22.99,A +"8-Bit Blitz #5",22.99,A +"Chromatic Co-op Week 41: NA Edition",22.96,A +"8-Bit Blitz #15",22.96,A +"Crobattle Royale: 6th Edition",22.96,A +"MIKU MIKU BEAM!!! 28",22.95,A +"Neon Wail by SAC #11",22.93,A +"Crobattle Royale: SWAGGER'S BIRTHDAY EDITION",22.90,A +"Shark Tank: Deep Waters #24",22.89,A +"Shark Tank: Deep Waters #26",22.88,A +"8-Bit Blitz #43",22.84,A +"Paddling Pool 252",22.83,A +"Megalodon Monday NA 15",22.81,A +"Homecoming Series: LeZone 4",22.80,A +"Prism Break 13",22.79,A +"Shark Tank: Deep Waters #10",22.74,A +"Chromatic Co-op Week 44: NA Edition",22.71,A +"Coral Clash: High Tide #25",22.71,A +"Squid Junction 85",22.70,A +"Solar Eclipse 50 / Pink Solar Eclipse",22.70,A +"Paddling Pool 302",22.67,A +"8-Bit Blitz #23",22.66,A +"Prism Break 4",22.65,A +"Maya’s Diamond Heist Jan 2026 (#1)",22.64,A +"Shark Tank: Deep Waters #2",22.64,A +"8-Bit Blitz #39",22.63,A +"Poker Table 5",22.58,A +"Solar Eclipse 18",22.57,A +"Solar Eclipse 41",22.57,A +"GHOST TYPE RUSH SHINY EDITION #5",22.54,A +"Solar Eclipse 40/ Pink Solar Eclipse",22.50,A +"Baja's Blast #19",22.48,A +"Chromatic Co-op Week 38: EU Edition/Cousin birthday bash",22.44,A +"GHOST TYPE RUSH #13",22.42,A +"8-Bit Blitz #19",22.42,A +"Shark Tank: Deep Waters #4",22.41,A +"Break Zach’s Bank #5",22.41,A +"Close & Personal 11 (TC Edition)",22.41,A +"Paddling Pool 263",22.39,A +"Lunar Eclipse 44",22.37,A +"Level Up 29",22.36,A +"8-Bit Blitz #7",22.34,A +"Prism Break 15",22.32,A +"Get Coupled 9",22.29,A +"Prism Break 6",22.29,A +"MIKU MIKU BEAM!!! #12",22.28,A +"8-Bit Blitz #42",22.27,A +"Tropical Tuesdays 27.1",22.27,A +"Level Up 36: Token Run",22.25,A +"Mesozoic Mayhem 12",22.24,A +"Moo Moo Cup #2.5 (1-Off NA Edition)",22.22,A +"Chromatic Co-op Week 19",22.21,A +"Dolphin Showdown 20",22.20,A +"All 4 One Grads Invitational #1",22.16,A +"Break Zach's Bank #4",22.12,A +"Prism Break 17",22.12,A +"Low Ink January 2025",22.08,A +"MIKU MIKU BEAM!!! #7",22.07,A +"Dolphin Showdown EU 1",22.05,A +"Kraken Royale 20",22.03,A +"Deadline #38",22.02,A +"Prism Break 3",21.99,A +"Tropical Tuesdays 24",21.98,A +"GHOST TYPE RUSH #7",21.97,A +"Crimson INK 14",21.97,A +"Solar Eclipse 22",21.96,A +"Shark Tank: Deep Waters #13",21.95,A +"K's Challenges #11: Starters Package",21.93,A +"SMOGON Starlight - Sizzle Season 2024",21.92,A +"Soul Cup : TestFire",21.92,A +"Coral Clash: High Tide #21",21.91,A +"Coral Clash: High Tide #15",21.89,A +"Megalodon Monday NA 36",21.89,A +"Moo Moo Cup #2",21.86,A +"Chromatic Co-op Week 17",21.85,A +"Oktofest 2025 Qualifier",21.85,A +"Neon Wail by SAC 15",21.82,A +"Chromatic Co-op Week 30: EU Edition",21.81,A +"Crimson INK 15",21.80,A +"Crobattle Royale: 35th Edition",21.79,A +"Level Up 63",21.79,A +"SAC Season 9 Qualifier 4",21.77,A +"Dolphin Showdown 8",21.73,A +"Urchin-Cup ",21.73,A +"Prism Break 24",21.71,A +"Shark Tank: Deep Waters #22",21.67,A +"Baja's Blast #20",21.66,A +"Chromatic Co-op Week 40: EU Edition",21.65,A +"Level Up 56",21.64,A +"Tropical Tuesdays 23",21.62,A +"8-Bit Blitz #18",21.62,A +"Get Coupled 18",21.62,A +"Baja's Blast #21",21.61,A +"Solar Eclipse 44",21.60,A +"Chromatic Co-op Week 33: NA Edition",21.57,A +"Crobattle Royale: 15th Edition (EU)",21.56,A +"Chromatic Co-op Week 21",21.54,A +"Level Up 21",21.53,A +"Close & Personal 12",21.52,A +"Prism Break 14",21.51,A +"Squid Junction 82",21.51,A +"Lunar Eclipse 16",21.48,A +"Kraken Royale 7",21.48,A +"Crobattle Royale: 23rd Edition",21.46,A +"8-Bit Blitz #4",21.45,A +"Chromatic Co-Op Week 27: NA Edition",21.45,A +"CCA Circuit Qualifier #3",21.44,A +"Silly Sausage Sunday #2: SUPER COOL",21.41,A +"Kraken Royale 11",21.41,A +"8-Bit Blitz #34",21.39,A +"Crobattle Royale: 24th Edition",21.38,A +"Mesozoic Mayhem 13",21.37,A +"Kraken Royale 3",21.32,A +"MIKU MIKU BEAM!!! #11",21.31,A +"Megalodon Monday EU 8",21.28,A +"Squid Junction 84",21.28,A +"Solar Eclipse 43",21.27,A +"Close & Personal 10",21.25,A +"Biggest Fish in the Sea 10: I'm Flying!",21.25,A +"Biggest Fish in the Sea",21.24,A +"Level Up EU #4: Token Run",21.24,A +"Squid Junction 83",21.22,A +"Low Ink May 2025",21.21,A +"Urchin-Cup",21.20,A +"Lemon Cider Birthday Bash!",21.20,A +"Urchin-Cup ",21.20,A +"Megalodon Monday NA 16",21.19,A +"Biggest Fish in the Sea 2: Old in the New",21.17,A +"Urchin-Cup ",21.15,A +"Solar Eclipse 15",21.12,A +"Lunar Eclipse 37",21.12,A +"Barracuda Boon 9",21.11,A +"Shark Tank: Deep Waters #6",21.10,A +"Mesozoic Mayhem 15",21.10,A +"Megalodon Monday NA 21",21.09,A +"Coral Clash: High Tide #26",21.09,A +"Moo Moo Cup #1",21.08,A +"8-Bit Blitz #44",21.07,A +"Break Zach’s Bank #3",21.07,A +"GHOST TYPE RUSH #14",21.07,A +"Level Up 32: Token Run",21.06,A +"Solar Eclipse 14",21.04,A +"Crobattle Royale: Second Edition",21.02,A +"Prism Break 23",21.01,A +"Mesozoic Mayhem 27",21.00,A +"Dual Ink #227",21.00,A +"Level Up 40: Shiny Hunt",21.00,B+ +"Paddling Pool 299",20.99,B+ +"8-Bit Blitz #22",20.98,B+ +"Kraken Royale 9",20.97,B+ +"Hardcore Mode #6",20.96,B+ +"Crobattle Royale: Fourth Edition",20.96,B+ +"Testing (also gambling)",20.94,B+ +"8-Bit Blitz #30",20.94,B+ +"Baja's Blast #6: SZ",20.93,B+ +"Megalodon Monday NA 29",20.93,B+ +"Coral Clash: High Tide #19",20.91,B+ +"Level Up 48: Shiny Hunt",20.91,B+ +"GHOST TYPE RUSH SHINY EDITION #25",20.88,B+ +"Prism Break 11",20.88,B+ +"Coral Clash: High Tide #12",20.86,B+ +"Megalodon Monday 12",20.86,B+ +"Level Up 46",20.81,B+ +"Biggest School in the Sea 5: Zones Only",20.80,B+ +"Shark Tank: Deep Waters #5",20.78,B+ +"Prism Break 19",20.76,B+ +"Stellaris Phantasm #7",20.76,B+ +"Crobattle Royale: 17th Edition",20.73,B+ +"Chromatic Co-op Week 51: EU Edition",20.73,B+ +"SAC Season 9 Finals",20.72,B+ +"Prism Break 5",20.72,B+ +"8-Bit Blitz #20",20.71,B+ +"Coral Clash: High Tide #13",20.70,B+ +"All 4 One Grads Invitational #2",20.70,B+ +"Level Up 23",20.69,B+ +"Level Up EU #1",20.67,B+ +"Prism Break 26",20.65,B+ +"Baja's Blast #27",20.64,B+ +"Megalodon Monday EU 15",20.62,B+ +"Deadline #34",20.61,B+ +"Level Up 37",20.60,B+ +"Party Wheel: On Ice",20.58,B+ +"Crobattle Royale: 30th Edition",20.57,B+ +"Megalodon Monday NA 24",20.56,B+ +"Level Up 24: Token Run",20.55,B+ +"Get Coupled 13",20.51,B+ +"GHOST TYPE RUSH #26",20.49,B+ +"Prism Break 8",20.47,B+ +"8-Bit Blitz #13",20.46,B+ +"Biggest Fish in the Sea 14: Now Introducing",20.46,B+ +"Shark Tank: Deep Waters #7",20.44,B+ +"Trans Cup Summer 2025",20.42,B+ +"Squid Junction 94",20.41,B+ +"Level Up 27",20.40,B+ +"8-Bit Blitz #17",20.39,B+ +"Kitty #1",20.38,B+ +"Close and Personal #19 (TC Edition)",20.37,B+ +"Baja's Blast #16",20.35,B+ +"Level Up 35",20.35,B+ +"Supply Closet #22 - Summer Edition (HBD Static and tomato !!)",20.33,B+ +"Dolphin Showdown 9",20.31,B+ +"Mesozoic Mayhem 14",20.30,B+ +"Crobattle Royale: 8th Edition",20.29,B+ +"GHOST TYPE RUSH #4",20.28,B+ +"K's Challenges #8: Out Of Bounds | Splatoon 3",20.26,B+ +"Mesozoic Mayhem 17",20.25,B+ +"TORNEO Crab Cancel Perfect 1vs1",20.25,B+ +"Get Coupled 4",20.24,B+ +"Dual Ink #209",20.21,B+ +"Monday Afterparty #1 ",20.20,B+ +"Coffee Date 6",20.20,B+ +"8-Bit Blitz #32",20.20,B+ +"Megalodon Monday NA 14",20.18,B+ +"Coral Clash: High Tide #17",20.15,B+ +"Proving Grounds 34",20.12,B+ +"8-Bit Blitz #6",20.12,B+ +"Kraken Royale 17",20.12,B+ +"Chromatic Co-op Week 49: EU Edition",20.11,B+ +"Coral Clash: High Tide #11",20.11,B+ +"Area of the Soul #2",20.10,B+ +"Run Speed Up 2",20.09,B+ +"Maya's Diamond Heist #0.5",20.08,B+ +"Distant Memory",20.08,B+ +"Chromatic Co-op Week 39: NA Edition",20.07,B+ +"Get Coupled 16",20.06,B+ +"Dolphin Showdown 7",20.02,B+ +"Shark Tank: 2v2 Edition",20.02,B+ +"Chromatic Co-Op Week 26: EU Edition",20.02,B+ +"Chromatic Co-Op Week 28: EU Edition",20.01,B+ +"MIKU MIKU BEAM!!! #17",19.99,B+ +"Final Life #9",19.96,B+ +"Solar Eclipse 25",19.94,B+ +"Coral Clash: High Tide #14",19.93,B+ +"Kitty 2",19.90,B+ +"Lunar Eclipse 45",19.89,B+ +"Coral Clash: High Tide #8",19.87,B+ +"Chromatic Co-op Week 45: EU Edition",19.87,B+ +"LAN Championship Showdown 2025",19.87,B+ +"Get Coupled 6",19.85,B+ +"Solar Eclipse 33",19.85,B+ +"Chromatic Co-op Week 58: NA Edition",19.83,B+ +"Megalodon Monday NA 13",19.81,B+ +"Chao Garden #1",19.77,B+ +"Prism Break 7",19.77,B+ +"Squid Junction 98",19.73,B+ +"Biggest Fish in the Sea 19: Shifting Tides",19.66,B+ +"Get Coupled 19",19.65,B+ +"8-Bit Blitz #16",19.64,B+ +"Chao Race",19.64,B+ +"Shrimp of the Day #08",19.63,B+ +"Megalodon Monday EU 1",19.63,B+ +"Dual Ink #231",19.60,B+ +"Level Up EU #2: Token Run",19.59,B+ +"Biggest School in the Sea 3: Zones Only",19.59,B+ +"Prism Break 21",19.58,B+ +"8-Bit Blitz #9",19.57,B+ +"SAC Season 10 Qualifier 4",19.55,B+ +"CCA Circuit Qualifier #1",19.55,B+ +"Solar Eclipse 42",19.52,B+ +"8-Bit Blitz #10",19.51,B+ +"Biggest Fish in the Sea 11: Old and the New",19.48,B+ +"Get Coupled 20",19.47,B+ +"8-Bit Blitz #26",19.47,B+ +"Dual Ink #213",19.46,B+ +"8-Bit Blitz #3",19.46,B+ +"Merryzoic Mayhem 26",19.44,B+ +"Level Up 60: Shiny Hunt",19.44,B+ +"Splashdown Saturday #5",19.42,B+ +"LUTI: Season 16 - Division 4",19.41,B+ +"Level Up 30: Token Run",19.41,B+ +"Break Zach's Bank #8",19.40,B+ +"Level Up 22: Token Run",19.39,B+ +"K's Challenges #12: Out Of Bounds",19.35,B+ +"Urchin-Cup ",19.35,B+ +"Homecoming Series: LeRainmakeur 4",19.35,B+ +"Lunar Eclipse 21",19.35,B+ +"Low Ink February 2025",19.32,B+ +"Maya's Diamond Heist #0",19.32,B+ +"Solar Eclipse 17",19.31,B+ +"Hardcore Mode #12",19.31,B+ +"Sayuri Escort 4",19.27,B+ +"Level Up 38: Token Run",19.26,B+ +"Prism Break 20",19.24,B+ +"Kraken Royale 8",19.23,B+ +"Silly Sausage Saturday #3: SUPER CRUMP",19.23,B+ +"Heaven 2 Ocean S1 Qual 3",19.22,B+ +"Chromatic Co-op Week 56: NA edition",19.22,B+ +"Dolphin Showdown 15",19.21,B+ +"Hardcore Mode #9",19.20,B+ +"Chromatic Co-op Week 23",19.20,B+ +"Break Zach’s Bank #6",19.17,B+ +"8-Bit Blitz #12",19.16,B+ +"GOLDEN Habibi 1v1",19.14,B+ +"F1 #0",19.14,B+ +"Dolphin Showdown 16",19.12,B+ +"Chromatic Co-op Week 42: NA Edition",19.11,B+ +"Shark Tank: Deep Waters",19.07,B+ +"Low Ink August 2025",19.06,B+ +"Prism Break 16",19.06,B+ +"Lunar Eclipse 17",19.00,B+ +"Coral Clash: High Tide #18",18.99,B+ +"8-Bit Blitz #24",18.99,B+ +"Power Pair Showdown 8",18.96,B+ +"Megalodon Monday EU 20",18.95,B+ +"Biggest Fish in the Sea 3: Seeing Red",18.94,B+ +"Close and Personal #17",18.94,B+ +"Mug Cup Mini 13 ",18.90,B+ +"Chromatic Co-Op Week 62: NA Edition",18.89,B+ +"Coral Clash: High Tide #20",18.88,B+ +"The Depths (Open) II",18.86,B+ +"8-Bit Blitz #28",18.85,B+ +"Chromatic Co-op Week 47: EU Edition",18.85,B+ +"SAC Season 10 Qualifier 3",18.81,B+ +"K's Challenges: Off-Brand Duels #18",18.77,B+ +"Solar Eclipse 28",18.70,B+ +"Solar Eclipse 19",18.64,B+ +"8-Bit Blitz #33",18.64,B+ +"Lunar Eclipse 30 / Pink Lunar Eclipse",18.58,B+ +"SAC Season 10 Qualifier 1",18.56,B+ +"GHOST TYPE RUSH #1",18.50,B+ +"Taste the Rainbow Invitational #1",18.47,B+ +"Solar Eclipse 38",18.44,B+ +"8-Bit Blitz #1",18.40,B+ +"Trans Cup Fall 2025",18.40,B+ +"Mesozoic Mayhem 19",18.37,B+ +"8-Bit Blitz #14",18.35,B+ +"Dual Ink #205",18.34,B+ +"Biggest School in the Sea 4: All Modes",18.32,B+ +"Low Ink June 2025",18.32,B+ +"Chromatic Co-op Week 55: EU Edition",18.24,B+ +"Moo Moo Cup #5",18.20,B+ +"(D3 Cap) Tristrike Tuesdays #25: TC + SZ",18.19,B+ +"Soul Cup - Go Sendou !",18.19,B+ +"Level Up 25",18.17,B+ +"Tropical Tuesdays 31",18.15,B+ +"Solar Eclipse 12",18.12,B+ +"Prism Break 12",18.11,B+ +"Get Coupled 10",18.09,B+ +"SplaTea Cup #7",18.06,B+ +"Baja’s Blast #2: TC",17.97,B+ +"Homecoming Series: LeZone 7",17.94,B+ +"Ctrl+Ink+Del #1",17.94,B+ +"Rush Weekend #3",17.86,B+ +"8-Bit Blitz #11",17.84,B+ +"Homecoming Series: LeCLam 3",17.83,B+ +"Crobattle Royale: 9th Edition",17.82,B+ +"Lunar Eclipse 50 / Pink Lunar Eclipse",17.77,B+ +"Deadline #29",17.74,B+ +"Hardcore Mode #11",17.74,B+ +"Kraken Royale 5",17.72,B+ +"Mug Cup Mini 12 : Pop up edition",17.70,B+ +"Soul Cup - Season Tournament",17.68,B+ +"Splashdown Saturday #4",17.68,B+ +"Summer Days #2",17.64,B+ +"Fountain of Dreams #3",17.64,B+ +"All 4 One #33: Golden Gauntlet",17.60,B+ +"Fountain of Dreams #4",17.60,B+ +"Mako Dona Cup #7 - Deli Aisle",17.51,B+ +"Squid Junction 80",17.51,B+ +"LUTI: Season 16 - Division 3",17.51,B+ +"8-Bit Blitz #21",17.51,B+ +"Power Pair Showdown 6",17.51,B+ +"Calm Before The Storm #6",17.49,B+ +"Lunar Eclipse 12",17.47,B+ +"Girls Duo Cup",17.47,B+ +"SAC Season 10 Finals",17.47,B+ +"Megalodon Monday NA 27",17.46,B+ +"Dual Ink #204",17.44,B+ +"K's Challenges: Underrated Meta #17",17.43,B+ +"Low Ink December 2024",17.39,B+ +"CCA Circuit Qualifier #6",17.38,B+ +"Coral Clash: High Tide #9",17.37,B+ +"8-Bit Blitz #8",17.30,B+ +"Power Pair Showdown 9",17.28,B+ +"Shark Tank: Deep Waters #23",17.22,B+ +"Prism Break: Metamorphosis (2)",17.21,B+ +"Solo Squid Showdown #2",17.20,B+ +"8-Bit Blitz #25",17.18,B+ +"Crobattle Royale: 20th Edition",17.18,B+ +"Baja's Blast #18",17.17,B+ +"GHOST TYPE RUSH #3",17.12,B+ +"SAC Season 10 Qualifier 2",17.10,B+ +"Kraken Royale 16",17.09,B+ +"Fountain of Dreams #5",17.08,B+ +"Get Coupled 17",17.06,B+ +"All 4 One #13: Golden Gauntlet",17.05,B+ +"CCA Circuit Qualifier #4",17.04,B+ +"Splashdown Saturday #2",16.99,B+ +"All 4 One #23: Golden Gauntlet",16.99,B+ +"Break Zach’s Bank #9",16.97,B+ +"Lunar Eclipse 14",16.95,B+ +"K's Challenges #13: Premiums Package",16.92,B+ +"Chromatic Co-Op Week 60: NA Edition",16.91,B+ +"Fountain of Dreams #10",16.88,B+ +"Octopi Palooza! #8 happy new year!",16.88,B+ +"Low Ink July 2025",16.85,B+ +"Tripnacity 6.0",16.84,B+ +"All 4 One #20: Splat Zones",16.84,B+ +"Megalodon Monday NA 26",16.80,B+ +"CCA Circuit Finals",16.80,B+ +"Fountain of Dreams #8",16.73,B+ +"Neon Wail by SAC #10",16.72,B+ +"All 4 One #26: Rainmaker",16.71,B+ +"Solar Eclipse 45",16.69,B+ +"Tristrike Tuesdays NA #51: SZ",16.69,B+ +"Lunar Eclipse 19",16.66,B+ +"K's Challenges: Out Of Bounds #15",16.66,B+ +"All 4 One #9: Tower Control",16.65,B+ +"8-Bit Blitz #41",16.62,B+ +"Moo Moo Cup #3",16.61,B+ +"Hardcore Mode #13",16.60,B+ +"Low Ink October 2025",16.59,B+ +"Supply Closet #15 - Art Edition",16.59,B+ +"Megalodon Monday NA 22",16.58,B+ +"Calm Before The Storm #8",16.54,B+ +"Tenta Missile Monday",16.52,B+ +"Urchin-Cup ",16.51,B+ +"Chromatic Co-Op Week 61: EU Edition",16.50,B+ +"Low Ink April 2025",16.45,B+ +"Dual Ink #212",16.43,B+ +"Mako Dona Cup #5 - Frozen Aisle",16.39,B+ +"Tristrike Tuesdays EU #2",16.38,B+ +"Deadline #30",16.35,B+ +"Turf War Takedown! #1",16.28,B+ +"All 4 One #31: Rainmaker",16.26,B+ +"S-BLAST '91",16.23,B+ +"All 4 One #28: Golden Gauntlet",16.19,B+ +"Fountain of Dreams #11",16.18,B+ +"Biggest Fish in the Sea 8: Back to Basics",16.18,B+ +"Biggest Fish in the Sea 12: A Nightmare End",16.16,B+ +"All 4 One #22: Clam Blitz",16.16,B+ +"All 4 One #11: Rainmaker",16.15,B+ +"All 4 One Invitational #3",16.14,B+ +"Prism Break 22",16.14,B+ +"Que siga la mata dando ",16.05,B+ +"(D3 Cap) Tristrike Tuesdays #28",15.99,B+ +"All 4 One #43: Golden Gauntlet",15.93,B+ +"Habibi 1v1 #41",15.89,B+ +"Tristrike Tuesdays: Inkstrike Invitational #1",15.88,B+ +"CCA Circuit Qualifier #2",15.86,B+ +"Biggest Fish in the Sea 4: What a Flex",15.84,B+ +"Dual Ink #217",15.84,B+ +"Chromatic Co-op Week 43: EU Edition",15.83,B+ +"Golden Diamond Heist #0",15.82,B+ +"Homecoming 3",15.79,B+ +"Chromatic Co-op Week 57: EU Edition",15.73,B+ +"Hardcore Mode 17",15.72,B+ +"Close and Personal #18",15.62,B+ +"(D3 Cap) Tristrike Tuesdays #26",15.62,B+ +"Megalodon Monday 11",15.59,B+ +"Fountain of Dreams #2",15.59,B+ +"Solar Eclipse 36",15.53,B+ +"Dualin' Horizons #7 Oops! All dualies!!",15.52,B+ +"Urchin-Cup",15.52,B+ +"Scrap Bin #8 - Sweethearts Special",15.51,B+ +"SplaTea Cup #6",15.50,B+ +"All 4 One #25: Splat Zones",15.50,B+ +"Solar Eclipse 34",15.46,B+ +"Ctrl+Ink+Del #3",15.42,B+ +"GHOST TYPE RUSH #22",15.41,B+ +"Dual Ink #210",15.41,B+ +"All 4 One #19: Tower Control",15.39,B+ +"Dualin' Horizons #3 Special-less",15.39,B+ +"All 4 One #10: Splat Zones",15.38,B+ +"Low Ink November 2025",15.35,B+ +"Fountain of Dreams #6",15.35,B+ +"Tristrike Tuesdays EU #3",15.35,B+ +"Fountain of Dreams #9",15.33,B+ +"All 4 One #14: Tower Control",15.31,B+ +"Switzerland #5",15.29,B+ +"(D3 Cap) Tristrike Tuesdays #31",15.24,B+ +"Tristrike Tuesday NA #40",15.19,B+ +"Hardcore Mode #14",15.17,B+ +"All 4 One #29: Tower Control",15.17,B+ +"Lunar Eclipse 40/ Pink Lunar Eclipse",15.15,B+ +"LUTI: Season 16 - Division 5",15.13,B+ +"Ctrl+Ink+Del #2",15.12,B+ +"Baja's Blast #5: TC",15.12,B+ +"All 4 One #30: Splat Zones",15.10,B+ +"Habibi 1v1 #36",15.07,B+ +"All 4 One #15: Splat Zones",15.07,B+ +"Mesozoic Mayhem 21",15.05,B+ +"Low Ink March 2025",15.05,B+ +"Ranked Refreshments #7: Lemon",15.05,B+ +"Tristrike Tuesday EU #5",15.05,B+ +"All 4 One #18: Golden Gauntlet",15.04,B+ +"Abandon Ship #8",15.01,B+ +"8-Bit Blitz #2",14.99,B +"Lunar Eclipse 33",14.97,B +"Get Coupled 12",14.96,B +"Shark Tank: 2v2 Edition ",14.95,B +"From The Ink Up 50",14.94,B +"All 4 One #40: Splat Zones",14.94,B +"Tropical Tuesdays 26.1",14.93,B +"Hardcore Mode #10 (Buddy Up)",14.89,B +"All 4 One Weekend Edition #7: Splat Zones",14.86,B +"GOLDEN Tropical Tuesdays 30",14.84,B +"Tristrike Tuesdays NA #36",14.82,B +"Get Coupled 14",14.82,B +"Dual Ink #225",14.81,B +"CCA Circuit Qualifier #5",14.80,B +"Switzerland #3",14.72,B +"Splashdown Saturday #6",14.66,B +"Dual Ink #232",14.66,B +"The Orchard Seeding Tournament #1 - Growing Season (Mango)",14.66,B +"arcade locker 1: one shot",14.63,B +"Canada Day!",14.61,B +"Biggest Fish in the Sea 23: 'Overpowered'",14.59,B +"MIKU MIKU BEAM!!! #14 TETO TAKEOVER!",14.56,B +"Biggest Fish in the Sea 13: Weapon Evolution",14.56,B +"Get Coupled 26",14.54,B +"(D3 Cap) Tristrike Tuesdays #29",14.49,B +"Rabbit Hole Cup",14.49,B +"Kraken Royale 6",14.48,B +"All 4 One #35: Splat Zones",14.48,B +"Ranked Refreshments #1: Lemon",14.46,B +"Tristrike Tuesdays EU #6",14.43,B +"Shark Tank: Deep Waters #21",14.39,B +"Tropical Tuesdays 20",14.37,B +"Crobattle Royale: 31st Edition",14.37,B +"Habibi 1v1 #27",14.36,B +"Tristrike Tuesdays NA #46",14.36,B +"Tristrike Tuesdays NA #38",14.34,B +"Lunar Eclipse 15",14.32,B +"Octopi Palooza! #7",14.30,B +"Biggest School in the Sea 6",14.29,B +"All 4 One Weekend Edition #6: Clam Blitz",14.23,B +"All 4 One #12: Clam Blitz",14.23,B +"Dual Ink #215",14.22,B +"GHOST TYPE RUSH #2",14.21,B +"Chromatic Co-Op Week 59: EU Edition",14.18,B +"BIG Mug Cup 14",14.18,B +"Tristrike Tuesday EU #4",14.14,B +"Squid Junction 90",14.10,B +"Coral Clash: High Tide #29",14.08,B +"8-Bit Blitz #37",14.07,B +"Baja's Blast #9: TC",14.06,B +"Dolphin Showdown 14",14.02,B +"Mug Cup Mini 14",13.99,B +"All 4 One #27: Clam Blitz",13.99,B +"Gimmick Series: Level Up 3",13.97,B +"Tristrike Tuesday NA #41",13.90,B +"Tristrike Tuesdays #35",13.87,B +"Final Life #5",13.85,B +"Tristrike Tuesdays EU #15: RM + SZ",13.84,B +"DSC #01",13.82,B +"All 4 One #16: Rainmaker",13.82,B +"Habibi 1v1 #25",13.80,B +"Barracuda Boon 11",13.80,B +"Close and Personal #15",13.80,B +"Splashdown Saturday #1",13.75,B +"Art Supplies Super Scrim Day FÜNF",13.75,B +"Minnow Cup #36: Splat Zones Edition",13.69,B +"Squid Advanced Circuit: Season 9 Qualifier 2 Bronze Bracket",13.67,B +"Tristrike Tuesdays NA #45",13.62,B +"All 4 One #45: Splat Zones",13.60,B +"All 4 One #21: Rainmaker",13.57,B +"Coral Clash: Low Tide #10",13.55,B +"Splatoon Decade | Day 1",13.54,B +"Switzerland #9",13.53,B +"Lunar Eclipse 49",13.52,B +"(D3 Cap) Tristrike Tuesdays #27",13.52,B +"All 4 One #38: Golden Gauntlet",13.47,B +"Tristrike Tuesdays EU #1",13.44,B +"From The Ink Up Week 63",13.44,B +"MIKU MIKU BEAM!!! #9 (D3 Sakura)",13.43,B +"Coral Clash: Low Tide #18",13.41,B +"Break Zach’s Bank #7",13.36,B +"All 4 One #36: Rainmaker",13.33,B +"Supply Closet #12 - Art Edition",13.30,B +"Splatoon Decade | Splatoon 3",13.29,B +"Get Coupled 22",13.28,B +"Megalodon Monday 10",13.27,B +"Tristrike Tuesdays EU #12: CB + SZ",13.27,B +"Tristrike Tuesdays EU #13: SZ",13.26,B +"Switzerland #22",13.22,B +"Tristrike Tuesdays NA #49: All Modes",13.19,B +"From The Ink Up Week 58",13.16,B +"Supply Closet #16 - Cleaning Edition",13.14,B +"Coral Clash: High Tide #10",13.10,B +"Mako Dona Cup #14 - Frozen Aisle",13.09,B +"All 4 One Saturdays #4: Tower Control",13.07,B +"Raise 4 RAICES",13.03,B +"Tristrike Tuesday NA #39",13.00,B +"Tristrike Tuesdays NA #37",13.00,B +"All 4 One Weekend Edition #12: Splat Zones",12.97,B +"Minnow Cup #38: Tower Control Edition",12.96,B +"Minnow cup #37: Rainmaker Edition",12.92,B +"Dilly-Dally Dualies #2",12.91,B +"(D3 Cap) Tristrike Tuesdays #30",12.90,B +"Stellaris Phantasm #4",12.88,B +"Supply Closet #14 - Evil Edition",12.87,B +"From the Ink Up Week 53",12.83,B +"From The Ink Up 56",12.78,B +"Fountain of Dreams #1",12.77,B +"Banning Legends 12 RE-RUN",12.75,B +"Tristrike Tuesdays NA #47: TC + SZ",12.75,B +"Tristrike Tuesdays NA #50: CB + SZ",12.66,B +"LUTI: Season 16 - Division 6",12.65,B +"Area of the Soul #1",12.64,B +"Tristrike Tuesdays NA #48: RM + SZ",12.64,B +"Shark Tank: Deep Waters #18",12.60,B +"Abandon Ship #3",12.57,B +"Dual Ink #214",12.56,B +"2v2 TUESDAYS #17",12.50,B +"Chromatic Co-op Week 48: NA Edition",12.48,B +"All 4 One #37: Clam Blitz",12.46,B +"Hardcore Mode #16",12.46,B +"Yaga's Restriction Tournament",12.45,B +"All 4 One #44: Tower Control",12.45,B +"Soul Rider #1",12.44,B +"Stellaris Phantasm #12 (Special edition)",12.41,B +"All 4 One #32: Clam Blitz",12.41,B +"Kraken Royale 4",12.39,B +"Tristrike Tuesdays NA #52: TC + SZ",12.35,B +"Zones Inkvasion #4",12.33,B +"Shrimp of the Day #15 - Clam Blitz",12.32,B +"Lunar Eclipse 26",12.31,B +"(D3 Cap) Tristrike Tuesdays #32",12.31,B +"Biggest Fish in the Sea 7: My Happy Place",12.31,B +"All 4 One Saturdays #2: Splat Zones",12.23,B +"Little Squid League 42",12.22,B +"From The Ink Up Week 59",12.18,B +"Revo'Cup レ #0",12.11,B +"All 4 One #39: Tower Control",12.11,B +"Fountain of Dreams #7",12.07,B +"All 4 One Weekend Edition #10: Golden Gauntlet",12.07,B +"Spooky Supply Closet #11 - Evil Edition ",12.00,B +"Coral Clash: Low Tide #13",11.99,B +"Habibi 1v1 #35",11.98,B +"Abandon Ship #7",11.97,B +"Tristrike Tuesdays NA #44",11.91,B +"From the Ink Up Week 54",11.91,B +"Banning Legends 2",11.91,B +"All 4 One #51: Rainmaker",11.90,B +"From The Ink Up Week 69",11.86,B +"Gimmick Series: First Come First Serve 3 - Special Edition",11.85,B +"Dual Ink #221",11.85,B +"Art Supplies Super Scrim Day FOUR ",11.84,B +"All 4 One #53: Golden Gauntlet",11.84,B +"Tristrike Tuesdays #34",11.83,B +"Coral Clash: Low Tide #19",11.80,B +"Meet Your Match #4",11.80,B +"All 4 One Saturdays #3: Rainmaker",11.79,B +"Distant Memory #2",11.78,B +"Basketsoul #1",11.77,B +"Habibi 1v1 #42",11.74,B +"Lunar Eclipse 31",11.71,B +"Coral Clash: Low Tide #24",11.69,B +"VERSUS THE WORLD #15",11.66,B +"All 4 One #17: Clam Blitz",11.66,B +"Abandon Ship #9",11.66,B +"Tristrike Tuesdays #6",11.64,B +"All 4 One #48: Golden Gauntlet",11.62,B +"From The Ink Up 52",11.57,B +"From the Ink Up Week 55",11.57,B +"Sheep Happens (except it's not Sheep Happens)",11.57,B +"All 4 One #24: Tower Control",11.52,B +"8-Bit Blitz #31",11.49,B +"Megalodon Monday 9",11.48,B +"Stellaris Phantasm #13",11.46,B +"Get Coupled 21",11.45,B +"Tristrike Tuesdays EU #9",11.44,B +"Coral Clash: Low Tide #23",11.43,B +"From The Ink Up 51",11.43,B +"Tristrike Tuesdays EU #7",11.42,B +"Tristrike Tuesdays #18: Rainmaker + SZ",11.41,B +"(D3 Cap) Tristrike Tuesdays #33",11.40,B +"Barracuda Boon 10",11.39,B +"Little Squid League 48",11.34,B +"Coral Clash: Low Tide #11",11.34,B +"Zones Weekly 42",11.32,B +"Minnow Cup #40: Splat Zones Edition",11.28,B +"Minnow Cup #35: Clam Blitz Edition",11.27,B +"Biggest Fish in the Sea 6: Double or Nothing",11.21,B +"Tristrike Tuesdays #19: Splat Zones",11.20,B +"Coral Clash: Low Tide #25",11.17,B +"From The Ink Up Week 61",11.14,B +"Mug Cup Mini 6",11.11,B +"Golden Ratio: Neus's Birthday Edition",11.08,B +"Abandon Ship #17",11.04,B +"just another 2v2 tourney",11.04,B +"Banning Legends 1",11.02,B +"All 4 One Weekend Edition #8: Rainmaker",10.98,B +"OCE Zones Weekly 32",10.97,B +"Tristrike Tuesdays NA #42",10.97,B +"Tristrike Tuesdays #10: All-Modes",10.97,B +"Zones Weekly 56",10.93,B +"All 4 One #34: Tower Control",10.91,B +"Little Squid League 46",10.89,B +"Climb The Spire #1",10.84,B +"Splashdown Saturday #3",10.82,B +"Little Squid League 43",10.81,B +"All 4 One Saturdays #5: Golden Gauntlet",10.79,B +"All 4 One #47: Clam Blitz",10.75,B +"Tristrike Tuesdays #21: TC + SZ",10.73,B +"Little Squid League 47",10.73,B +"DSC #02",10.69,B +"Coral Clash: Low Tide #12",10.65,B +"INK TORRENT 1: SPLAT ZONES",10.63,B +"OCE Zones Weekly 31",10.63,B +"Frostbite Ascent: Drizzle Season 2024",10.62,B +"Coral Clash: Low Tide #15",10.61,B +"Moo Moo Cup #4",10.58,B +"All 4 One #50: Splat Zones",10.58,B +"All 4 One #41: Rainmaker",10.56,B +"Solar Eclipse 47",10.53,B +"Abandon Ship #15 - Jxvito Day!",10.53,B +"Stellaris Phantasm #3",10.52,B +"Frostbite Ascent: Chill Season 2024",10.48,B +"All 4 One #49: Tower Control",10.48,B +"Tristrike Tuesdays #24: SZ",10.46,B +"K's Challenges: New World #19",10.43,B +"Solar Eclipse 46",10.41,B +"Low Ink December 2025",10.38,B +"Close and Personal #20",10.37,B +"BUBBLE BASH",10.36,B +"Dual Ink #211",10.34,B +"CULT 4: SORELLA BRELLA",10.30,B +"Tristrike Tuesdays #17: Clam Blitz + SZ",10.30,B +"MIKU MIKU BEAM!!! #3 (Sakura)",10.29,B +"Zoestrike Tuesdays #14: Splat Zones",10.28,B +"Ranked Refreshments #8: Lime",10.28,B +"OCE Zones Weekly 25",10.26,B +"Cosmic Clash: Pilot Event",10.24,B +"Tristrike Tuesdays EU #11",10.23,B +"Abandon Ship #18",10.21,B +"Tristrike Tuesdays NA #43",10.19,B +"Shrimp of the Day #09",10.18,B +"Little Squid League 45",10.13,B +"Tristrike Tuesdays EU #14: TC + SZ",10.11,B +"Taste the Rainbow Invitational #2",10.10,B +"Tristrike Tuesdays: Oops All Tristrikes!",10.10,B +"From The Ink Up Week 62",10.07,B +"Little Squid League 44",10.04,B +"From The Ink Up Week 65",10.03,B +"UK Mafia Draft Cup",10.03,B +"Scaryzoic Mayhem 29",9.97,C+ +"Tristrike Tuesdays EU #10",9.90,C+ +"Tristrike Tuesdays 11: Tower Control + SZ",9.88,C+ +"Get Coupled 5",9.81,C+ +"Tristrike Tuesdays #12: Clam Blitz + SZ",9.81,C+ +"Supply Closet #18 - Evil Edition",9.72,C+ +"Coral Clash: Low Tide #14",9.71,C+ +"All 4 One #46: Rainmaker",9.70,C+ +"Abandon Ship #16",9.67,C+ +"Chromatic Co-op Week 34: EU Edition",9.67,C+ +"Coral Clash: Low Tide #9",9.64,C+ +"Switzerland #25",9.59,C+ +"Coral Clash: Low Tide #16",9.56,C+ +"MIKU MIKU BEAM!!! #13 (D5 Sakura)",9.53,C+ +"Deadline #28",9.51,C+ +"From The Ink Up Week 67",9.44,C+ +"All 4 One #42: Clam Blitz",9.40,C+ +"All 4 One Weekend Edition #11: Clam Blitz",9.39,C+ +"GOLDEN 2V2 TUESDAYS",9.36,C+ +"Zones Weekly 44",9.31,C+ +"Coffee Date 5",9.30,C+ +"Milkcup #2",9.29,C+ +"Get Coupled 11",9.29,C+ +"Get Coupled 24",9.28,C+ +"All 4 One #52: Clam Blitz",9.28,C+ +"Tristrike Tuesdays EU #16: All Modes",9.22,C+ +"Abandon Ship #19",9.22,C+ +"Zones Weekly 40: International Week",9.18,C+ +"Green Sea Pickles 5",9.18,C+ +"Octopi Palooza! #6",9.16,C+ +"Climb The Spire 3",9.15,C+ +"Abandon Ship #14",9.14,C+ +"United LAN: Splatoon 3 4v4 Tournament",9.11,C+ +"Octopi Palooza! #5",9.10,C+ +"Minnow Cup #41: Rainmaker Edition",9.10,C+ +"Mug Cup #15 ",9.08,C+ +"Supply Closet #13 - Cleaning Edition",9.04,C+ +"Coral Clash: Low Tide #17",9.03,C+ +"Ranked Refreshments #2: Lime",9.03,C+ +"Coral Clash: Low Tide #26",9.01,C+ +"Close and Personal #20 (TC Edition)",8.99,C+ +"Custom Challenges #3: Sabrina Carbonter! ",8.99,C+ +"From The Ink Up Week 64",8.93,C+ +"Stellaris Phantasm #5",8.93,C+ +"Habibi 1v1 #17",8.91,C+ +"All 4 One #54: Tower Control",8.90,C+ +"Green Sea Pickles 2",8.89,C+ +"Get Coupled 15",8.80,C+ +"Tristrike Tuesdays #16: Tower Control + SZ",8.76,C+ +"Next Wave Nexus #1",8.76,C+ +"Coral Clash: Low Tide #28",8.75,C+ +"Shrimp of the Day #17 - Tower Control ",8.59,C+ +"2v2 TUESDAYS #19",8.57,C+ +"Stellaris Phantasm #9",8.51,C+ +"GOLDEN 2v2 TUESDAYS",8.51,C+ +"Final Life #7",8.48,C+ +"OCE Zones Weekly 27",8.43,C+ +"Zones Weekly 37",8.38,C+ +"Sheep Happens 1v1 #2",8.36,C+ +"Just Another 2v2",8.34,C+ +"All 4 One Weekend Edition #13: Rainmaker",8.34,C+ +"Get Coupled 25",8.31,C+ +"Shrimp of the Day #10",8.29,C+ +"All 4 One Weekend Edition #9: Tower Control",8.26,C+ +"2v2 TUESDAYS #12",8.23,C+ +"LUTI: Season 16 - Division 7",8.21,C+ +"Tristrike Tuesdays #20: All-Modes",8.19,C+ +"Abandon Ship #5",8.17,C+ +"Scrap Bin #10 - Bubble Bath 2",8.12,C+ +"From The Ink Up Week 66",8.09,C+ +"Switzerland #27",8.06,C+ +"Climb The Spire 13",8.04,C+ +"Climb The Spire 25",8.02,C+ +"Tristrike Tuesdays #15: All-Modes",8.02,C+ +"Stellaris Phantasm #16",8.01,C+ +"Tristrike Tuesdays #13: Rainmaker + SZ",7.98,C+ +"Tristrike Tuesdays #22: CB + SZ",7.98,C+ +"Get Coupled 23",7.91,C+ +"Habibi 1v1 #39 ",7.91,C+ +"OCE Zones Weekly 19",7.88,C+ +"Tristrike Tuesdays #7: Clam Blitz (+SZ)",7.88,C+ +"Minnow Cup #39: Clam Blitz Edition",7.88,C+ +"CCA Circuit Last Chance Qualifier",7.80,C+ +"Climb The Spire 2",7.77,C+ +"Abandon Ship #4",7.75,C+ +"Tristrike Tuesdays #8: Rainmaker (+SZ)",7.73,C+ +"Climb The Spire 23",7.73,C+ +"Banning Legends 29 - EXPERIMENTAL EDITION",7.70,C+ +"Zones Weekly 39",7.69,C+ +"From The Ink Up Week 60",7.67,C+ +"Tristrike Tuesdays #23: TC + SZ",7.65,C+ +"Abandon Ship #24 FINALE",7.63,C+ +"Inkversus 2024",7.62,C+ +"Coral Clash: Low Tide #22",7.57,C+ +"Stellaris Phantasm #11",7.54,C+ +"Abandon Ship #6",7.54,C+ +"From The Ink Up Week 71",7.51,C+ +"Stellaris Phantasm #6 (only zones edition)",7.47,C+ +"Zones Weekly 20: International Week",7.45,C+ +"LUTI: Season 16 - Division 9 World",7.43,C+ +"CULT: S-BLAST '91",7.42,C+ +"LUTI: Season 16 - Division 8",7.39,C+ +"Abandon Ship #23 ",7.36,C+ +"Switzerland #19",7.33,C+ +"Close and Personal #23",7.33,C+ +"OCE Zones Weekly 17",7.32,C+ +"Abandon Ship #20",7.30,C+ +"Dualin' Horizons #5 Limitless",7.26,C+ +"DSC #03",7.24,C+ +"Supply Closet #10 - Cleaning Edition",7.24,C+ +"Dolphin Showdown NA 11",7.23,C+ +"Milkcup #4",7.23,C+ +"Coral Clash: Low Tide #27",7.20,C+ +"Tristrike Tuesdays #3",7.20,C+ +"Coral Clash: Low Tide #20",7.19,C+ +"2v2 TUESDAYS #29",7.18,C+ +"Biggest Fish in the Sea 18: One Shot",7.15,C+ +"From The Ink Up Week 57",7.12,C+ +"Climb The Spire 24",7.12,C+ +"Coral Clash: Low Tide #29",7.10,C+ +"Get Coupled 8",7.09,C+ +"Tristrike Tuesdays #4",7.08,C+ +"From The Ink Up Week 70",7.07,C+ +"K's Challenges: Stealthy Safeguard #16",6.96,C+ +"Little Squid League 50",6.90,C+ +"Abandon Ship #21",6.89,C+ +"Deadline #49",6.88,C+ +"2v2 TUESDAYS #40",6.86,C+ +"The Depths (Skill Capped) #6",6.80,C+ +"Stellaris Phantasm #8",6.72,C+ +"Climb The Spire 11",6.69,C+ +"Stellaris Phantasm #2",6.65,C+ +"Smollusk Low-Level Spire",6.63,C+ +"From The Ink Up Week 68",6.60,C+ +"Shark Tank: Shallow Waters #3",6.57,C+ +"Sunfish Saturday #3",6.57,C+ +"Abandon Ship #22",6.50,C+ +"Tristrike Tuesdays #5",6.50,C+ +"From The Ink Up Week 77",6.49,C+ +"From The Ink Up Week 73",6.47,C+ +"From The Ink Up Week 78",6.43,C+ +"Reach #7",6.33,C+ +"Climb The Spire 14",6.32,C+ +"Switzerland #26",6.30,C+ +"Little Squid League 49",6.30,C+ +"Reach #6",6.24,C+ +"Mug Cup 18#",6.19,C+ +"Tristrike Tuesdays EU #8",6.11,C+ +"Coral Clash: Low Tide #30",6.04,C+ +"Climb The Spire 15",6.02,C+ +"Moonlight Coffee Date",6.00,C+ +"Habibi 1v1 #43",5.94,C+ +"Abandon Ship #2",5.82,C+ +"From The Ink Up Week 81",5.81,C+ +"From The Ink Up Week 76",5.78,C+ +"Climb The Spire 9",5.74,C+ +"Supply Closet #21 - Evil Edition",5.72,C+ +"Mug Cup #22",5.71,C+ +"Supply Closet #9 - Art Edition",5.63,C+ +"Supply Closet #26 - Evil Edition",5.54,C+ +"Switzerland #21",5.54,C+ +"Coral Clash: Low Tide #31",5.54,C+ +"SBA N Co-ops +L (Pt 3)",5.48,C+ +"Twilight Zones #0 - The Beta Test",5.45,C+ +"TSS (EU) #17",5.41,C+ +"Climb The Spire 26",5.40,C+ +"Abandon Ship #12",5.38,C+ +"Get Coupled 27",5.31,C+ +"Switzerland #15",5.23,C+ +"Habibi 1v1 #31",5.21,C+ +"OCE Zones Weekly 29",5.17,C+ +"From The Ink Up Week 80",5.08,C+ +"Stellaris Phantasm #10",5.05,C+ +"Shrimp of the Day #18 - Rainmaker",4.97,C +"Biggest Fish in the Sea 9: A New Era",4.88,C +"From The Ink Up Week 72",4.86,C +"The Orchard Seeding Tournament #2 - Growing Season (Durian)",4.80,C +"From The Ink Up Week 84",4.77,C +"Mug Cup #20",4.72,C +"From The Ink Up Week 82",4.72,C +"Stellaris Phantasm #15",4.61,C +"[OMD] Bingo Night #2",4.60,C +"Coral Clash: Low Tide #21",4.59,C +"LUTI: Season 16 - Division 9 Americas",4.58,C +"DSB Shopping Spree 2",4.53,C +"Climb The Spire 17",4.49,C +"Mug Cup #17",4.45,C +"SHARK TANK #1",4.37,C +"Charger Assault #5",4.33,C +"+L x SBA & Co 2v2",4.30,C +"From The Ink Up Week 88",4.26,C +"Minus One Cup #4",4.24,C +"From The Ink Up Week 79",4.19,C +"Milkcup #3",4.14,C +"Squidfin Fireboard #18",4.11,C +"Milkcup #5",4.02,C +"Climb The Spire 21",4.02,C +"From the Ink Up 92",4.02,C +"Switzerland #18",3.83,C +"From The Ink Up Week 75",3.76,C +"Mug Cup 23",3.72,C +"Shark Tank: Shallow Waters #12",3.71,C +"Climb The Spire 30",3.62,C +"From the Ink Up 95",3.60,C +"Habibi 1v1 #33",3.60,C +"Reach #5",3.56,C +"Squidfin Fireboard #13",3.51,C +"Stellaris Phantasm #14",3.51,C +"Shark Tank: Shallow Waters #7",3.48,C +"Abandon Ship #11",3.48,C +"Turnabout Suspect",3.47,C +"Climb The Spire 12",3.47,C +"Mug Cup #21",3.45,C +"World 9: Mission 21",3.44,C +"From the Ink Up 94",3.37,C +"DS Side Quest #01: Deck of Destiny",3.37,C +"Climb The Spire 5",3.37,C +"Switzerland #17",3.34,C +"Turf War Tomatoes #2 (Rollers)",3.34,C +"Shark Tank: Shallow Waters #6",3.31,C +"Stellaris Phantasm #17",3.23,C +"Climb The Spire 18",3.21,C +"Climb The Spire 27",3.18,C +"SHARK TANK #2",3.15,C +"Climb The Spire 28",3.09,C +"Switzerland #24",3.08,C +"Minus One Cup #6",3.05,C +"Biggest Fish in the Sea 15: BONK!",3.00,C +"From The Ink Up Week 83",2.97,C +"Milkcup #1",2.86,C +"Shark Tank: Shallow Waters; Homeland Edition #5",2.85,C +"2v2 TUESDAYS #6 ",2.83,C +"Reach #10",2.82,C +"From The Ink Up Week 74",2.80,C +"Shark Tank: Shallow Waters #4",2.72,C +"Shark Tank: Shallow Waters #16",2.72,C +"Reach #13",2.68,C +"Cap the Zone 9",2.66,C +"From the Ink Up Week 89",2.65,C +"MIDLINES ONLY",2.64,C +"7 Seas #1",2.62,C +"Shark Tank: Shallow Waters #10",2.56,C +"TSS (EU) #15",2.56,C +"Climb The Spire 22",2.54,C +"Squidfin Fireboard #11",2.50,C +"Mug Cup 19#",2.49,C +"Reach #11",2.44,C +"Setting Sail",2.25,C +"Climb The Spire 19",2.20,C +"+L Tournament #1",2.19,C +"Teacup #8",2.18,C +"Shark Tank: Shallow Waters #11 ",2.14,C +"Teacup #10",2.07,C +"From The Ink Up Week 86",2.01,C +"Reach #15",2.00,C +"Custom Challenges #0: Specials^4!",1.98,C +"Shark Tank: Shallow Waters #29",1.97,C +"Shark Tank: Shallow Waters; Homeland Edition #4",1.92,C +"Mug Cup 27",1.91,C +"Climb The Spire 29",1.88,C +"Shark Tank: Shallow Waters #17",1.88,C +"Teacup #18",1.88,C +"Reach #14",1.86,C +"Habibi 1v1 #32",1.82,C +"Ranked Refreshments #9: Dragonfruit",1.77,C +"From The Ink Up Week 87",1.75,C +"From the Ink Up 93",1.70,C +"Setting Sail #2",1.69,C +"Clam Slam #0 - OOTBALL!",1.68,C +"From the Ink Up 91",1.67,C +"Climb The Spire 6",1.64,C +"Teacup #22",1.63,C +"Reach #12",1.61,C +"Climb The Spire 34",1.60,C +"Cosmic Clash: Nebula Skirmish #1",1.51,C +"Leblanc Hideout League Season 2: Seeding Tournament",1.46,C +"Teacup #19",1.45,C +"From The Ink Up Week 85",1.44,C +"+L Tournament #2",1.41,C +"Climb The Spire 33",1.40,C +"World 9: Mission 9",1.31,C +"World 9: Mission 16",1.30,C +"Shark: Tank Shallow Waters #19",1.30,C +"World 9: Mission 15",1.30,C +"Shark Tank: Shallow Waters #8",1.29,C +"Shark Tank: Shallow Waters #30",1.26,C +"Teacup #23",1.23,C +"SBA & Co. 2v2",1.20,C +"Shark tank: Shallow Waters #18",1.13,C +"Minus One Cup #1",1.12,C +"Setting Sail #5",1.11,C +"Shark Tank: Shallow Waters #9",1.02,C +"Shark Tank: Shallow Waters #15 ",1.01,C +"Mug Cup 25",0.92,C +"From the Ink Up Week 90",0.89,C +"Shark Tank: Shallow Waters #22",0.76,C +"Squidfin Fireboard #20",0.66,C +"Mug Cup 29",0.65,C +"Hotlantis' Corner Store! #1 (d7 cap)",0.64,C +"CCA Circuit Qualifier #1 - Div 2",0.62,C +"Teacup #21",0.57,C +"Dualin' Horizons #2 (D6 cap)",0.54,C +"World 9: Mission 20",0.53,C +"Shark Tank: Shallow Waters #27",0.52,C +"Setting Sail #3",0.48,C +"Shark Tank: Shallow Waters #25",0.37,C +"Shark Tank: Shallow Waters #13",0.35,C +"Abandon Ship #10",0.35,C +"Mug Cup 28",0.34,C +"World 9: Mission 18",0.29,C +"Mug Cup #16",0.27,C +"Shark Tank: Shallow Waters; Homeland Edition #3",0.27,C +"Cosmic Clash: Nebula Skirmish #2",0.15,C +"Tough Tides League 1",0.13,C +"Habibi 1v1 #18",0.10,C +"CCA Circuit Qualifier #1 - Div 3",0.09,C +"Teacup #16",0.04,C +"PSL Cup - Pre-season Edition Part 2",0.03,C +"Shark Tank: Shallow Waters #21",0.02,C +"MIKU MIKU BEAM!!! #6 (D7 Sakura)",0.01,C +"Teacup #20",-0.00,C +"Climb The Spire 32",-0.05,C +"Teacup #17",-0.07,C +"World 9: Mission 17",-0.09,C +"TSS (NA) #16",-0.14,C +"Reach #9",-0.17,C +"Shark Tank: Shallow Waters #26",-0.21,C +"Shark Tank: Shallow Waters #20",-0.26,C +"Squidfin Fireboard #17",-0.29,C +"Shark Tank: Shallow Waters #5",-0.30,C +"Reach #8",-0.34,C +"Academy Showcase Season 1",-0.34,C +"Paralarvae Parfait 2",-0.35,C +"Setting Sail #4",-0.38,C +"7 Seas #2",-0.40,C +"Shark Tank: Shallow Waters #28",-0.40,C +"Shark Tank: Shallow Waters; Homeland Edition #2.5",-0.48,C +"Battleship of the dictatorship - epic magical 2v2's!!!",-0.50,C +"Shark Tank: Shallow Waters #24",-0.56,C +"Shark Tank: Shallow Waters #14",-0.64,C +"DS Next Wave #02",-0.69,C +"Paralarvae Parfait January 2026",-0.74,C +"Abandon Ship #13",-0.75,C +"Switzerland #23",-0.76,C +"Switzerland #13",-0.77,C +"Teacup #11",-0.81,C +"TSS #14 (EU)",-0.85,C +"Mug Cup 24",-0.86,C +"World 9: Mission 19",-0.92,C +"Teacup #15",-1.02,C +"Squidfin Fireboard #19",-1.04,C +"World 9: Mission 12",-1.05,C +"Shark Tank: Shallow Waters #23",-1.12,C +"Teacup #14",-1.26,C +"Teacup #12",-1.29,C +"Shark Tank Shallow Waters: Homeland Edition #0",-1.34,C +"DS Next Wave #1",-1.36,C +"Fracture cup",-1.49,C +"Teacup #24",-1.51,C +"PEÑA WARRIOR",-1.51,C +"Ranked Refreshments #6: Dragonfruit",-1.76,C +"TSS #11",-1.79,C +"World 9: Mission 14",-1.79,C +"MIKU MIKU BEAM!!! #16 (D7 Sakura)",-1.83,C +"Mug Cup 26",-1.84,C +"Mug Cup 34",-1.99,C +"Dualin' Horizons #4 Special-less (D6 cap)",-1.99,C +"World 9: Mission 11",-2.17,C +"Academy Showcase Season 2",-2.24,C +"Supply Closet #25 - Cleaning Edition (Capped)",-2.59,C +"PEÑA WARRIOR #3",-2.84,C +"Supply Closet #23 - Summer Edition (Capped)",-2.93,C +"Squidfin Fireboard #16",-3.09,C +"Shark Tank: Shallow Waters; Homeland Edition #1",-3.11,C +"Sheep Happens #2 (Div 7 cap!)",-3.23,C +"World 9: Mission 10",-3.49,C +"Teacup #13",-3.60,C +"Mug Cup 32",-3.68,C +"World 9: Mission 13",-3.77,C +"Mug Cup 30",-4.04,C +"Mug Cup 31",-4.43,C