mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
League support (#2030)
* Initial * Create league divs script works * Progress * Progress * Prevent round from starting * Finalized? * Tweaks * linter
This commit is contained in:
parent
9a671457a2
commit
86b50ced56
|
|
@ -30,3 +30,5 @@ VITE_SKALOP_WS_URL=ws://localhost:5900
|
|||
|
||||
// trunc, full or none (default: none)
|
||||
SQL_LOG=trunc
|
||||
|
||||
VITE_SHOW_LUTI_NAV_ITEM=false
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Link } from "@remix-run/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import navItems from "~/components/layout/nav-items.json";
|
||||
import { navItems } from "~/components/layout/nav-items";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { LOG_OUT_URL, navIconUrl, userPage } from "~/utils/urls";
|
||||
import { Avatar } from "../Avatar";
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
[
|
||||
{
|
||||
"name": "settings",
|
||||
"url": "settings",
|
||||
"prefetch": true
|
||||
},
|
||||
{
|
||||
"name": "sendouq",
|
||||
"url": "q",
|
||||
"prefetch": false
|
||||
},
|
||||
{
|
||||
"name": "analyzer",
|
||||
"url": "analyzer",
|
||||
"prefetch": true
|
||||
},
|
||||
{
|
||||
"name": "builds",
|
||||
"url": "builds",
|
||||
"prefetch": true
|
||||
},
|
||||
{
|
||||
"name": "object-damage-calculator",
|
||||
"url": "object-damage-calculator",
|
||||
"prefetch": true
|
||||
},
|
||||
{
|
||||
"name": "leaderboards",
|
||||
"url": "leaderboards",
|
||||
"prefetch": false
|
||||
},
|
||||
{
|
||||
"name": "lfg",
|
||||
"url": "lfg",
|
||||
"prefetch": false
|
||||
},
|
||||
{
|
||||
"name": "plans",
|
||||
"url": "plans",
|
||||
"prefetch": false
|
||||
},
|
||||
{
|
||||
"name": "badges",
|
||||
"url": "badges",
|
||||
"prefetch": false
|
||||
},
|
||||
{
|
||||
"name": "calendar",
|
||||
"url": "calendar",
|
||||
"prefetch": false
|
||||
},
|
||||
{
|
||||
"name": "plus",
|
||||
"url": "plus/suggestions",
|
||||
"prefetch": false
|
||||
},
|
||||
{
|
||||
"name": "u",
|
||||
"url": "u",
|
||||
"prefetch": false
|
||||
},
|
||||
{
|
||||
"name": "xsearch",
|
||||
"url": "xsearch",
|
||||
"prefetch": false
|
||||
},
|
||||
{
|
||||
"name": "articles",
|
||||
"url": "a",
|
||||
"prefetch": false
|
||||
},
|
||||
{
|
||||
"name": "vods",
|
||||
"url": "vods",
|
||||
"prefetch": false
|
||||
},
|
||||
{
|
||||
"name": "art",
|
||||
"url": "art",
|
||||
"prefetch": false
|
||||
},
|
||||
{
|
||||
"name": "t",
|
||||
"url": "t",
|
||||
"prefetch": false
|
||||
},
|
||||
{
|
||||
"name": "links",
|
||||
"url": "links",
|
||||
"prefetch": true
|
||||
},
|
||||
{
|
||||
"name": "maps",
|
||||
"url": "maps",
|
||||
"prefetch": false
|
||||
}
|
||||
]
|
||||
104
app/components/layout/nav-items.ts
Normal file
104
app/components/layout/nav-items.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
export const navItems = [
|
||||
{
|
||||
name: "settings",
|
||||
url: "settings",
|
||||
prefetch: true,
|
||||
},
|
||||
import.meta.env.VITE_SHOW_LUTI_NAV_ITEM === "true"
|
||||
? {
|
||||
name: "luti",
|
||||
url: "luti",
|
||||
prefetch: false,
|
||||
}
|
||||
: null,
|
||||
{
|
||||
name: "sendouq",
|
||||
url: "q",
|
||||
prefetch: false,
|
||||
},
|
||||
{
|
||||
name: "analyzer",
|
||||
url: "analyzer",
|
||||
prefetch: true,
|
||||
},
|
||||
{
|
||||
name: "builds",
|
||||
url: "builds",
|
||||
prefetch: true,
|
||||
},
|
||||
{
|
||||
name: "object-damage-calculator",
|
||||
url: "object-damage-calculator",
|
||||
prefetch: true,
|
||||
},
|
||||
{
|
||||
name: "leaderboards",
|
||||
url: "leaderboards",
|
||||
prefetch: false,
|
||||
},
|
||||
{
|
||||
name: "lfg",
|
||||
url: "lfg",
|
||||
prefetch: false,
|
||||
},
|
||||
{
|
||||
name: "plans",
|
||||
url: "plans",
|
||||
prefetch: false,
|
||||
},
|
||||
{
|
||||
name: "badges",
|
||||
url: "badges",
|
||||
prefetch: false,
|
||||
},
|
||||
{
|
||||
name: "calendar",
|
||||
url: "calendar",
|
||||
prefetch: false,
|
||||
},
|
||||
{
|
||||
name: "plus",
|
||||
url: "plus/suggestions",
|
||||
prefetch: false,
|
||||
},
|
||||
{
|
||||
name: "u",
|
||||
url: "u",
|
||||
prefetch: false,
|
||||
},
|
||||
{
|
||||
name: "xsearch",
|
||||
url: "xsearch",
|
||||
prefetch: false,
|
||||
},
|
||||
{
|
||||
name: "articles",
|
||||
url: "a",
|
||||
prefetch: false,
|
||||
},
|
||||
{
|
||||
name: "vods",
|
||||
url: "vods",
|
||||
prefetch: false,
|
||||
},
|
||||
{
|
||||
name: "art",
|
||||
url: "art",
|
||||
prefetch: false,
|
||||
},
|
||||
{
|
||||
name: "t",
|
||||
url: "t",
|
||||
prefetch: false,
|
||||
},
|
||||
{
|
||||
name: "links",
|
||||
url: "links",
|
||||
prefetch: true,
|
||||
},
|
||||
{
|
||||
name: "maps",
|
||||
url: "maps",
|
||||
prefetch: false,
|
||||
},
|
||||
].filter((item) => item !== null);
|
||||
|
|
@ -98,6 +98,10 @@ const calendarEventWithToToolsDepths = () => calendarEventWithToTools("DEPTHS");
|
|||
const calendarEventWithToToolsTeamsDepths = () =>
|
||||
calendarEventWithToToolsTeams("DEPTHS");
|
||||
|
||||
const calendarEventWithToToolsLUTI = () => calendarEventWithToTools("LUTI");
|
||||
const calendarEventWithToToolsTeamsLUTI = () =>
|
||||
calendarEventWithToToolsTeams("LUTI");
|
||||
|
||||
const basicSeeds = (variation?: SeedVariation | null) => [
|
||||
adminUser,
|
||||
makeAdminPatron,
|
||||
|
|
@ -145,6 +149,8 @@ const basicSeeds = (variation?: SeedVariation | null) => [
|
|||
calendarEventWithToToolsToSetMapPool,
|
||||
calendarEventWithToToolsDepths,
|
||||
calendarEventWithToToolsTeamsDepths,
|
||||
calendarEventWithToToolsLUTI,
|
||||
calendarEventWithToToolsTeamsLUTI,
|
||||
tournamentSubs,
|
||||
adminBuilds,
|
||||
manySplattershotBuilds,
|
||||
|
|
@ -218,6 +224,12 @@ function wipeDB() {
|
|||
];
|
||||
|
||||
for (const table of tablesToDelete) {
|
||||
if (table === "Tournament") {
|
||||
// foreign key constraint reasons
|
||||
sql
|
||||
.prepare("delete from Tournament where parentTournamentId is not null")
|
||||
.run();
|
||||
}
|
||||
sql.prepare(`delete from "${table}"`).run();
|
||||
}
|
||||
}
|
||||
|
|
@ -861,7 +873,7 @@ async function calendarEventResults() {
|
|||
|
||||
const TO_TOOLS_CALENDAR_EVENT_ID = 201;
|
||||
function calendarEventWithToTools(
|
||||
event: "PICNIC" | "ITZ" | "PP" | "SOS" | "DEPTHS" = "PICNIC",
|
||||
event: "PICNIC" | "ITZ" | "PP" | "SOS" | "DEPTHS" | "LUTI" = "PICNIC",
|
||||
registrationOpen = false,
|
||||
) {
|
||||
const tournamentId = {
|
||||
|
|
@ -870,6 +882,7 @@ function calendarEventWithToTools(
|
|||
PP: 3,
|
||||
SOS: 4,
|
||||
DEPTHS: 5,
|
||||
LUTI: 6,
|
||||
}[event];
|
||||
const eventId = {
|
||||
PICNIC: TO_TOOLS_CALENDAR_EVENT_ID + 0,
|
||||
|
|
@ -877,6 +890,7 @@ function calendarEventWithToTools(
|
|||
PP: TO_TOOLS_CALENDAR_EVENT_ID + 2,
|
||||
SOS: TO_TOOLS_CALENDAR_EVENT_ID + 3,
|
||||
DEPTHS: TO_TOOLS_CALENDAR_EVENT_ID + 4,
|
||||
LUTI: TO_TOOLS_CALENDAR_EVENT_ID + 5,
|
||||
}[event];
|
||||
const name = {
|
||||
PICNIC: "PICNIC #2",
|
||||
|
|
@ -884,6 +898,7 @@ function calendarEventWithToTools(
|
|||
PP: "Paddling Pool 253",
|
||||
SOS: "Swim or Sink 101",
|
||||
DEPTHS: "The Depths 5",
|
||||
LUTI: "Leagues Under The Ink Season 15",
|
||||
}[event];
|
||||
|
||||
const settings: Tables["Tournament"]["settings"] =
|
||||
|
|
@ -987,16 +1002,34 @@ function calendarEventWithToTools(
|
|||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
bracketProgression: [
|
||||
{
|
||||
type: "double_elimination",
|
||||
name: "Main bracket",
|
||||
requiresCheckIn: false,
|
||||
settings: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
: event === "LUTI"
|
||||
? {
|
||||
bracketProgression: [
|
||||
{
|
||||
type: "round_robin",
|
||||
name: "Groups stage",
|
||||
requiresCheckIn: false,
|
||||
settings: {},
|
||||
},
|
||||
{
|
||||
type: "single_elimination",
|
||||
name: "Play-offs",
|
||||
requiresCheckIn: false,
|
||||
settings: {},
|
||||
sources: [{ bracketIdx: 0, placements: [1, 2] }],
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
bracketProgression: [
|
||||
{
|
||||
type: "double_elimination",
|
||||
name: "Main bracket",
|
||||
requiresCheckIn: false,
|
||||
settings: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
sql
|
||||
.prepare(
|
||||
|
|
@ -1016,7 +1049,11 @@ function calendarEventWithToTools(
|
|||
id: tournamentId,
|
||||
settings: JSON.stringify(settings),
|
||||
mapPickingStyle:
|
||||
event === "SOS" ? "TO" : event === "ITZ" ? "AUTO_SZ" : "AUTO_ALL",
|
||||
event === "SOS" || event === "LUTI"
|
||||
? "TO"
|
||||
: event === "ITZ"
|
||||
? "AUTO_SZ"
|
||||
: "AUTO_ALL",
|
||||
});
|
||||
|
||||
sql
|
||||
|
|
@ -1156,7 +1193,7 @@ const availablePairs = rankedModesShort
|
|||
)
|
||||
.filter((pair) => !tiebreakerPicks.has(pair));
|
||||
function calendarEventWithToToolsTeams(
|
||||
event: "PICNIC" | "ITZ" | "PP" | "SOS" | "DEPTHS" = "PICNIC",
|
||||
event: "PICNIC" | "ITZ" | "PP" | "SOS" | "DEPTHS" | "LUTI" = "PICNIC",
|
||||
isSmall = false,
|
||||
) {
|
||||
const userIds = userIdsInAscendingOrderById();
|
||||
|
|
@ -1170,6 +1207,7 @@ function calendarEventWithToToolsTeams(
|
|||
PP: 3,
|
||||
SOS: 4,
|
||||
DEPTHS: 5,
|
||||
LUTI: 6,
|
||||
}[event];
|
||||
|
||||
const teamIdAddition = {
|
||||
|
|
@ -1178,6 +1216,7 @@ function calendarEventWithToToolsTeams(
|
|||
PP: 200,
|
||||
SOS: 300,
|
||||
DEPTHS: 400,
|
||||
LUTI: 500,
|
||||
}[event];
|
||||
|
||||
for (let id = 1; id <= (isSmall ? 4 : 16); id++) {
|
||||
|
|
@ -1212,8 +1251,8 @@ function calendarEventWithToToolsTeams(
|
|||
inviteCode: nanoid(INVITE_CODE_LENGTH),
|
||||
});
|
||||
|
||||
// in PICNIC & PP Chimera is not checked in
|
||||
if (teamId !== 1 && teamId !== 201) {
|
||||
// in PICNIC & PP Chimera is not checked in + in LUTI no check-ins at all
|
||||
if (teamId !== 1 && teamId !== 201 && event !== "LUTI") {
|
||||
sql
|
||||
.prepare(
|
||||
`
|
||||
|
|
@ -1268,7 +1307,11 @@ function calendarEventWithToToolsTeams(
|
|||
});
|
||||
}
|
||||
|
||||
if (event !== "SOS" && (Math.random() < 0.8 || id === 1)) {
|
||||
if (
|
||||
event !== "SOS" &&
|
||||
event !== "LUTI" &&
|
||||
(Math.random() < 0.8 || id === 1)
|
||||
) {
|
||||
const shuffledPairs = shuffle(availablePairs.slice());
|
||||
|
||||
let SZ = 0;
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export const db = new Kysely<DB>({
|
|||
dialect: new SqliteDialect({
|
||||
database: sql,
|
||||
}),
|
||||
log: LOG_LEVEL === "trunc" || LOG_LEVEL === "full" ? logQuery : undefined,
|
||||
log: LOG_LEVEL === "trunc" || LOG_LEVEL === "full" ? logQuery : logError,
|
||||
plugins: [new ParseJSONResultsPlugin()],
|
||||
});
|
||||
|
||||
|
|
@ -54,6 +54,23 @@ function logQuery(event: LogEvent) {
|
|||
);
|
||||
// biome-ignore lint/suspicious/noConsoleLog: dev only
|
||||
console.log(formatSql(event.query.sql, event.query.parameters));
|
||||
} else {
|
||||
logError(event);
|
||||
}
|
||||
}
|
||||
|
||||
function logError(event: LogEvent) {
|
||||
if (
|
||||
event.level === "error" &&
|
||||
// it seems that this error happens everytime something goes wrong inside transaction
|
||||
// my guess is that the transaction is already implicitly rolled back in the case of error
|
||||
// but kysely also does it explicitly -> fails because there is no transaction to rollback.
|
||||
// this `logError` function at least makes it so that due to that the error doesn't get logged
|
||||
// but of course the best solution would also avoid useless rollbacks, something for the future
|
||||
// btw this particular check is here just to avoid the double "no transaction is active" log
|
||||
!(event.error as any).message.includes("no transaction is active")
|
||||
) {
|
||||
console.error(event.error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ export interface CalendarEvent {
|
|||
name: string;
|
||||
participantCount: number | null;
|
||||
tags: string | null;
|
||||
hidden: Generated<number>;
|
||||
tournamentId: number | null;
|
||||
organizationId: number | null;
|
||||
avatarImgId: number | null;
|
||||
|
|
@ -455,6 +456,8 @@ export interface Tournament {
|
|||
string | null
|
||||
>;
|
||||
rules: string | null;
|
||||
/** Related "parent tournament", the tournament that contains the original sign-ups (for leagues) */
|
||||
parentTournamentId: number | null;
|
||||
}
|
||||
|
||||
export interface PreparedMaps {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import { UserSearch } from "~/components/UserSearch";
|
|||
import { useUser } from "~/features/auth/core/user";
|
||||
import { FRIEND_CODE_REGEXP_PATTERN } from "~/features/sendouq/q-constants";
|
||||
import { isAdmin, isMod } from "~/permissions";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { makeTitle } from "~/utils/strings";
|
||||
import { SEED_URL, STOP_IMPERSONATING_URL, impersonateUrl } from "~/utils/urls";
|
||||
|
||||
|
|
@ -27,10 +26,6 @@ export const meta: MetaFunction = () => {
|
|||
return [{ title: makeTitle("Admin page") }];
|
||||
};
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
navItemName: "admin",
|
||||
};
|
||||
|
||||
export default function AdminPage() {
|
||||
const user = useUser();
|
||||
|
||||
|
|
|
|||
|
|
@ -215,6 +215,7 @@ export async function findAllBetweenTwoTimestamps({
|
|||
"<=",
|
||||
dateToDatabaseTimestamp(endTime),
|
||||
)
|
||||
.where("CalendarEvent.hidden", "=", 0)
|
||||
.orderBy("CalendarEventDate.startTime", "asc");
|
||||
|
||||
for (const tag of tagsToFilterBy) {
|
||||
|
|
@ -362,6 +363,7 @@ export async function eventsToReport(authorId: number) {
|
|||
fn.max("CalendarEventDate.startTime").as("startTime"),
|
||||
])
|
||||
.where("CalendarEvent.authorId", "=", authorId)
|
||||
.where("CalendarEvent.hidden", "=", 0)
|
||||
.where("startTime", ">=", dateToDatabaseTimestamp(oneMonthAgo))
|
||||
.where("startTime", "<=", dateToDatabaseTimestamp(new Date()))
|
||||
.where("CalendarEvent.participantCount", "is", null)
|
||||
|
|
@ -382,6 +384,7 @@ export async function findRecentMapPoolsByAuthorId(authorId: number) {
|
|||
withMapPool(eb),
|
||||
])
|
||||
.where("CalendarEvent.authorId", "=", authorId)
|
||||
.where("CalendarEvent.hidden", "=", 0)
|
||||
.orderBy("CalendarEvent.id", "desc")
|
||||
.groupBy("CalendarEvent.id")
|
||||
.limit(5)
|
||||
|
|
@ -484,6 +487,7 @@ type CreateArgs = Pick<
|
|||
avatarFileName?: string;
|
||||
avatarImgId?: number;
|
||||
autoValidateAvatar?: boolean;
|
||||
parentTournamentId?: number;
|
||||
};
|
||||
export async function create(args: CreateArgs) {
|
||||
const copiedStaff = args.tournamentToCopyId
|
||||
|
|
@ -527,6 +531,7 @@ export async function create(args: CreateArgs) {
|
|||
.values({
|
||||
mapPickingStyle: args.mapPickingStyle,
|
||||
settings: JSON.stringify(settings),
|
||||
parentTournamentId: args.parentTournamentId,
|
||||
rules: args.rules,
|
||||
})
|
||||
.returning("id")
|
||||
|
|
@ -568,6 +573,7 @@ export async function create(args: CreateArgs) {
|
|||
bracketUrl: args.bracketUrl,
|
||||
avatarImgId: args.avatarImgId ?? avatarImgId,
|
||||
organizationId: args.organizationId,
|
||||
hidden: args.parentTournamentId ? 1 : 0,
|
||||
tournamentId,
|
||||
})
|
||||
.returning("id")
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { KeyIcon } from "~/components/icons/Key";
|
|||
import { LogOutIcon } from "~/components/icons/LogOut";
|
||||
import { SearchIcon } from "~/components/icons/Search";
|
||||
import { UsersIcon } from "~/components/icons/Users";
|
||||
import navItems from "~/components/layout/nav-items.json";
|
||||
import { navItems } from "~/components/layout/nav-items";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import type * as Changelog from "~/features/front-page/core/Changelog.server";
|
||||
import {
|
||||
|
|
@ -34,6 +34,7 @@ import {
|
|||
BLANK_IMAGE_URL,
|
||||
CALENDAR_TOURNAMENTS_PAGE,
|
||||
LOG_OUT_URL,
|
||||
LUTI_PAGE,
|
||||
SENDOUQ_PAGE,
|
||||
leaderboardsPage,
|
||||
navIconUrl,
|
||||
|
|
@ -55,6 +56,7 @@ export const handle: SendouRouteHandle = {
|
|||
export default function FrontPage() {
|
||||
return (
|
||||
<Main className="front-page__container">
|
||||
<LeagueBanner />
|
||||
<DesktopSideNav />
|
||||
<SeasonBanner />
|
||||
<TournamentCards />
|
||||
|
|
@ -158,6 +160,19 @@ function SeasonBanner() {
|
|||
);
|
||||
}
|
||||
|
||||
function LeagueBanner() {
|
||||
const showBannerFor = import.meta.env.VITE_SHOW_BANNER_FOR_SEASON;
|
||||
if (!showBannerFor) return null;
|
||||
|
||||
return (
|
||||
<Link to={LUTI_PAGE} className="front__luti-banner">
|
||||
<Image path={navIconUrl("luti")} size={24} alt="" />
|
||||
Registration now open for Leagues Under The Ink (LUTI) Season{" "}
|
||||
{showBannerFor}!
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function TournamentCards() {
|
||||
const { t } = useTranslation(["front"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import clsx from "clsx";
|
||||
import type { TournamentRoundMaps } from "~/db/tables";
|
||||
import { useTournament } from "~/features/tournament/routes/to.$id";
|
||||
import { resolveLeagueRoundStartDate } from "~/features/tournament/tournament-utils";
|
||||
import { useAutoRerender } from "~/hooks/useAutoRerender";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useDeadline } from "./useDeadline";
|
||||
|
|
@ -17,6 +19,8 @@ export function RoundHeader({
|
|||
showInfos?: boolean;
|
||||
maps?: TournamentRoundMaps | null;
|
||||
}) {
|
||||
const leagueRoundStartDate = useLeagueWeekStart(roundId);
|
||||
|
||||
const hasDeadline = ![
|
||||
"WB Finals",
|
||||
"Grand Finals",
|
||||
|
|
@ -36,7 +40,7 @@ export function RoundHeader({
|
|||
return (
|
||||
<div>
|
||||
<div className="elim-bracket__round-header">{name}</div>
|
||||
{showInfos && bestOf ? (
|
||||
{showInfos && bestOf && !leagueRoundStartDate ? (
|
||||
<div className="elim-bracket__round-header__infos">
|
||||
<div>
|
||||
{countPrefix}
|
||||
|
|
@ -45,6 +49,16 @@ export function RoundHeader({
|
|||
</div>
|
||||
{hasDeadline ? <Deadline roundId={roundId} bestOf={bestOf} /> : null}
|
||||
</div>
|
||||
) : leagueRoundStartDate ? (
|
||||
<div className="elim-bracket__round-header__infos">
|
||||
<div>
|
||||
{leagueRoundStartDate.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}{" "}
|
||||
→
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="elim-bracket__round-header__infos invisible">
|
||||
Hidden
|
||||
|
|
@ -75,3 +89,9 @@ function Deadline({ roundId, bestOf }: { roundId: number; bestOf: number }) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useLeagueWeekStart(roundId: number) {
|
||||
const tournament = useTournament();
|
||||
|
||||
return resolveLeagueRoundStartDate(tournament, roundId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { SubmitButton } from "~/components/SubmitButton";
|
|||
import { EditIcon } from "~/components/icons/Edit";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { useTournament } from "~/features/tournament/routes/to.$id";
|
||||
import { resolveLeagueRoundStartDate } from "~/features/tournament/tournament-utils";
|
||||
import invariant from "~/utils/invariant";
|
||||
import * as PickBan from "../core/PickBan";
|
||||
import type { TournamentDataTeam } from "../core/Tournament.server";
|
||||
|
|
@ -223,6 +224,7 @@ function ReportScoreButtons({
|
|||
matchLocked: boolean;
|
||||
newScore: [number, number];
|
||||
}) {
|
||||
const data = useLoaderData<TournamentMatchLoaderData>();
|
||||
const user = useUser();
|
||||
const tournament = useTournament();
|
||||
const confirmCheckId = React.useId();
|
||||
|
|
@ -230,6 +232,18 @@ function ReportScoreButtons({
|
|||
const [endConfirmation, setEndConfirmation] = React.useState(false);
|
||||
const [pointConfirmation, setPointConfirmation] = React.useState(false);
|
||||
|
||||
const leagueRoundStartDate = resolveLeagueRoundStartDate(
|
||||
tournament,
|
||||
data.match.roundId,
|
||||
);
|
||||
if (leagueRoundStartDate && leagueRoundStartDate > new Date()) {
|
||||
return (
|
||||
<p className="tournament-bracket__during-match-actions__amount-warning-paragraph">
|
||||
League round has not started yet
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (matchLocked) {
|
||||
return (
|
||||
<p className="tournament-bracket__during-match-actions__amount-warning-paragraph">
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { PickIcon } from "~/components/icons/Pick";
|
|||
import { useUser } from "~/features/auth/core/user";
|
||||
import { Chat, useChat } from "~/features/chat/components/Chat";
|
||||
import { useTournament } from "~/features/tournament/routes/to.$id";
|
||||
import { resolveLeagueRoundStartDate } from "~/features/tournament/tournament-utils";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useSearchParamState } from "~/hooks/useSearchParamState";
|
||||
import type { StageId } from "~/modules/in-game-lists";
|
||||
|
|
@ -311,6 +312,14 @@ function FancyStageBanner({
|
|||
return null;
|
||||
})();
|
||||
|
||||
const waitingForLeagueRoundToStart = (() => {
|
||||
const date = resolveLeagueRoundStartDate(tournament, data.match.roundId);
|
||||
|
||||
if (!date) return false;
|
||||
|
||||
return date > new Date();
|
||||
})();
|
||||
|
||||
return (
|
||||
<>
|
||||
{inBanPhase ? (
|
||||
|
|
@ -337,6 +346,22 @@ function FancyStageBanner({
|
|||
<div>Please wait for staff to unlock</div>
|
||||
</div>
|
||||
</div>
|
||||
) : waitingForLeagueRoundToStart ? (
|
||||
<div className="tournament-bracket__locked-banner">
|
||||
<div className="stack sm items-center">
|
||||
<div className="text-lg text-center font-bold">
|
||||
Waiting for league round to start
|
||||
</div>
|
||||
<div>
|
||||
Round playable from{" "}
|
||||
{resolveLeagueRoundStartDate(
|
||||
tournament,
|
||||
data.match.roundId,
|
||||
)!.toLocaleDateString()}{" "}
|
||||
onwards
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : waitingForActiveRosterSelectionFor ? (
|
||||
<div className="tournament-bracket__locked-banner">
|
||||
<div className="stack sm items-center">
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
import { TOURNAMENT } from "~/features/tournament";
|
||||
import type * as Progression from "~/features/tournament-bracket/core/Progression";
|
||||
import * as Standings from "~/features/tournament/core/Standings";
|
||||
import { LEAGUES } from "~/features/tournament/tournament-constants";
|
||||
import { tournamentIsRanked } from "~/features/tournament/tournament-utils";
|
||||
import type { TournamentManagerDataSet } from "~/modules/brackets-manager/types";
|
||||
import type { Match, Stage } from "~/modules/brackets-model";
|
||||
|
|
@ -588,7 +589,9 @@ export class Tournament {
|
|||
|
||||
return {
|
||||
groupCount: Math.ceil(participantsCount / teamsPerGroup),
|
||||
seedOrdering: ["groups.seed_optimized"],
|
||||
seedOrdering: [
|
||||
this.isLeagueDivision ? "natural" : "groups.seed_optimized",
|
||||
],
|
||||
};
|
||||
}
|
||||
case "swiss": {
|
||||
|
|
@ -903,6 +906,16 @@ export class Tournament {
|
|||
return this.ctx.settings.autonomousSubs ?? true;
|
||||
}
|
||||
|
||||
get isLeagueSignup() {
|
||||
return Object.values(LEAGUES)
|
||||
.flat()
|
||||
.some((entry) => entry.tournamentId === this.ctx.id);
|
||||
}
|
||||
|
||||
get isLeagueDivision() {
|
||||
return Boolean(this.ctx.parentTournamentId);
|
||||
}
|
||||
|
||||
matchNameById(matchId: number) {
|
||||
let bracketName: string | undefined;
|
||||
let roundName: string | undefined;
|
||||
|
|
|
|||
|
|
@ -7154,6 +7154,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
|||
ctx: {
|
||||
id: 815,
|
||||
eventId: 2614,
|
||||
parentTournamentId: null,
|
||||
discordUrl: "https://discord.gg/F7RaNUR",
|
||||
tags: "LOW",
|
||||
settings: {
|
||||
|
|
|
|||
|
|
@ -1999,6 +1999,7 @@ export const SWIM_OR_SINK_167 = (
|
|||
},
|
||||
ctx: {
|
||||
id: 672,
|
||||
parentTournamentId: null,
|
||||
eventId: 2425,
|
||||
discordUrl: "https://discord.gg/F7RaNUR",
|
||||
tags: null,
|
||||
|
|
|
|||
|
|
@ -1979,6 +1979,7 @@ export const PADDLING_POOL_257 = () =>
|
|||
ctx: {
|
||||
id: 27,
|
||||
organization: null,
|
||||
parentTournamentId: null,
|
||||
tags: null,
|
||||
eventId: 1352,
|
||||
bracketProgressionOverrides: [],
|
||||
|
|
@ -7916,6 +7917,7 @@ export const PADDLING_POOL_255 = () =>
|
|||
ctx: {
|
||||
id: 18,
|
||||
organization: null,
|
||||
parentTournamentId: null,
|
||||
tags: null,
|
||||
eventId: 1286,
|
||||
bracketProgressionOverrides: [],
|
||||
|
|
@ -14188,6 +14190,7 @@ export const IN_THE_ZONE_32 = ({
|
|||
},
|
||||
ctx: {
|
||||
id: 11,
|
||||
parentTournamentId: null,
|
||||
organization: null,
|
||||
tags: null,
|
||||
eventId: 1134,
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ export const testTournament = ({
|
|||
tags: null,
|
||||
description: null,
|
||||
organization: null,
|
||||
parentTournamentId: null,
|
||||
rules: null,
|
||||
logoUrl: null,
|
||||
logoSrc: "/test.png",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
import { tournamentIdFromParams } from "~/features/tournament/tournament-utils";
|
||||
import { notFoundIfFalsy } from "~/utils/remix.server";
|
||||
import type { Unwrapped } from "../../../utils/types";
|
||||
import { tournamentFromDB } from "../core/Tournament.server";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await getUser(request);
|
||||
const tournamentId = tournamentIdFromParams(params);
|
||||
|
||||
const divisions = notFoundIfFalsy(await divisionsCached(tournamentId));
|
||||
|
||||
return {
|
||||
divisions,
|
||||
divsParticipantOf: user
|
||||
? divisions
|
||||
.filter((division) => division.participantUserIds.has(user?.id))
|
||||
.map((division) => division.tournamentId)
|
||||
: [],
|
||||
};
|
||||
};
|
||||
|
||||
// no purge mechanism in code but new divisions are created so rarely we just reboot the server when it is done
|
||||
const tournamentDivisionsCache = new Map<
|
||||
number,
|
||||
Array<Unwrapped<typeof TournamentRepository.findChildTournaments>>
|
||||
>();
|
||||
|
||||
async function divisionsCached(tournamentId: number) {
|
||||
if (!tournamentDivisionsCache.has(tournamentId)) {
|
||||
const tournament = await tournamentFromDB({
|
||||
tournamentId,
|
||||
user: undefined,
|
||||
});
|
||||
|
||||
if (!tournament.isLeagueSignup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
tournamentDivisionsCache.set(
|
||||
tournamentId,
|
||||
await TournamentRepository.findChildTournaments(tournamentId),
|
||||
);
|
||||
}
|
||||
|
||||
return tournamentDivisionsCache.get(tournamentId)!;
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ const stm = sql.prepare(/* sql */ `
|
|||
"TournamentMatch"."bestOf",
|
||||
"TournamentMatch"."chatCode",
|
||||
"Tournament"."mapPickingStyle",
|
||||
"TournamentRound"."id" as "roundId",
|
||||
"TournamentRound"."maps" as "roundMaps",
|
||||
json_group_array(
|
||||
json_object(
|
||||
|
|
@ -60,6 +61,7 @@ export const findMatchById = (id: number) => {
|
|||
"id" | "groupId" | "opponentOne" | "opponentTwo" | "bestOf" | "chatCode"
|
||||
> &
|
||||
Pick<Tournament, "mapPickingStyle"> & { players: string }) & {
|
||||
roundId: number;
|
||||
roundMaps: string | null;
|
||||
})
|
||||
| undefined;
|
||||
|
|
@ -73,6 +75,7 @@ export const findMatchById = (id: number) => {
|
|||
return {
|
||||
...row,
|
||||
bestOf: (roundMaps?.count ?? row.bestOf) as 3 | 5 | 7,
|
||||
roundId: row.roundId,
|
||||
roundMaps,
|
||||
opponentOne: JSON.parse(row.opponentOne) as Match["opponent1"],
|
||||
opponentTwo: JSON.parse(row.opponentTwo) as Match["opponent2"],
|
||||
|
|
|
|||
|
|
@ -430,6 +430,10 @@ export default function TournamentBracketsPage() {
|
|||
).length;
|
||||
};
|
||||
|
||||
if (tournament.isLeagueSignup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{visibility !== "hidden" && !tournament.everyBracketOver ? (
|
||||
|
|
|
|||
55
app/features/tournament-bracket/routes/to.$id.divisions.tsx
Normal file
55
app/features/tournament-bracket/routes/to.$id.divisions.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import type { SerializeFrom } from "@remix-run/node";
|
||||
import { Link, useLoaderData } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UsersIcon } from "../../../components/icons/Users";
|
||||
import { tournamentBracketsPage } from "../../../utils/urls";
|
||||
import { loader } from "../loader/to.$id.divisions.server";
|
||||
export { loader };
|
||||
|
||||
export default function TournamentDivisionsPage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
if (data.divisions.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-lg font-semi-bold text-lighter">
|
||||
Divisions have not been released yet, check back later
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack horizontal md flex-wrap">
|
||||
{data.divisions.map((div) => (
|
||||
<DivisionLink key={div.tournamentId} div={div} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DivisionLink({
|
||||
div,
|
||||
}: { div: SerializeFrom<typeof loader>["divisions"][number] }) {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { t } = useTranslation(["calendar"]);
|
||||
const shortName = div.name.split("-").at(-1);
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={tournamentBracketsPage({ tournamentId: div.tournamentId })}
|
||||
className={clsx("tournament__div__link", {
|
||||
tournament__div__link__participant: data.divsParticipantOf.includes(
|
||||
div.tournamentId,
|
||||
),
|
||||
})}
|
||||
>
|
||||
{shortName}
|
||||
<div className="tournament__div__participant-counts">
|
||||
<UsersIcon />{" "}
|
||||
{t("calendar:count.teams", {
|
||||
count: div.teamsCount,
|
||||
})}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -46,6 +46,7 @@ export async function findById(id: number) {
|
|||
"Tournament.castedMatchesInfo",
|
||||
"Tournament.mapPickingStyle",
|
||||
"Tournament.rules",
|
||||
"Tournament.parentTournamentId",
|
||||
"CalendarEvent.name",
|
||||
"CalendarEvent.description",
|
||||
"CalendarEventDate.startTime",
|
||||
|
|
@ -321,6 +322,40 @@ function nullifyingAvg(values: number[]) {
|
|||
return values.reduce((acc, cur) => acc + cur, 0) / values.length;
|
||||
}
|
||||
|
||||
export async function findChildTournaments(parentTournamentId: number) {
|
||||
const rows = await db
|
||||
.selectFrom("Tournament")
|
||||
.innerJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId")
|
||||
.select((eb) => [
|
||||
"Tournament.id as tournamentId",
|
||||
"CalendarEvent.name",
|
||||
eb
|
||||
.selectFrom("TournamentTeam")
|
||||
.select(({ fn }) => [fn.countAll<number>().as("teamsCount")])
|
||||
.whereRef("TournamentTeam.tournamentId", "=", "Tournament.id")
|
||||
.as("teamsCount"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("TournamentTeam")
|
||||
.innerJoin(
|
||||
"TournamentTeamMember",
|
||||
"TournamentTeamMember.tournamentTeamId",
|
||||
"TournamentTeam.id",
|
||||
)
|
||||
.select(["TournamentTeamMember.userId"])
|
||||
.whereRef("TournamentTeam.tournamentId", "=", "Tournament.id"),
|
||||
).as("teamMembers"),
|
||||
])
|
||||
.where("Tournament.parentTournamentId", "=", parentTournamentId)
|
||||
.$narrowType<{ teamsCount: NotNull }>()
|
||||
.execute();
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
participantUserIds: new Set(row.teamMembers.map((member) => member.userId)),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function findTOSetMapPoolById(tournamentId: number) {
|
||||
return (
|
||||
await db
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { INVITE_CODE_LENGTH } from "~/constants";
|
|||
import { db } from "~/db/sql";
|
||||
import type { DB, Tables } from "~/db/tables";
|
||||
import { databaseTimestampNow } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
|
||||
export function setActiveRoster({
|
||||
teamId,
|
||||
|
|
@ -141,6 +142,89 @@ export function create({
|
|||
});
|
||||
}
|
||||
|
||||
export function copyFromAnotherTournament({
|
||||
tournamentTeamId,
|
||||
destinationTournamentId,
|
||||
}: { tournamentTeamId: number; destinationTournamentId: number }) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const oldTeam = await trx
|
||||
.selectFrom("TournamentTeam")
|
||||
.select([
|
||||
"TournamentTeam.avatarImgId",
|
||||
"TournamentTeam.createdAt",
|
||||
"TournamentTeam.name",
|
||||
"TournamentTeam.noScreen",
|
||||
"TournamentTeam.prefersNotToHost",
|
||||
"TournamentTeam.teamId",
|
||||
|
||||
// -- exclude these
|
||||
// "TournamentTeam.id"
|
||||
// "TournamentTeam.droppedOut"
|
||||
// "TournamentTeam.activeRosterUserIds"
|
||||
// "TournamentTeam.seed"
|
||||
// "TournamentTeam.startingBracketIdx"
|
||||
// "TournamentTeam.inviteCode"
|
||||
// "TournamentTeam.tournamentId"
|
||||
// "TournamentTeam.activeRosterUserIds",
|
||||
])
|
||||
.where("id", "=", tournamentTeamId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const oldMembers = await trx
|
||||
.selectFrom("TournamentTeamMember")
|
||||
.select([
|
||||
"TournamentTeamMember.createdAt",
|
||||
"TournamentTeamMember.inGameName",
|
||||
"TournamentTeamMember.isOwner",
|
||||
"TournamentTeamMember.userId",
|
||||
|
||||
// -- exclude these
|
||||
// "TournamentTeamMember.tournamentTeamId"
|
||||
])
|
||||
.where("tournamentTeamId", "=", tournamentTeamId)
|
||||
.execute();
|
||||
invariant(oldMembers.length > 0, "Team has no members");
|
||||
|
||||
const oldMapPool = await trx
|
||||
.selectFrom("MapPoolMap")
|
||||
.select(["MapPoolMap.mode", "MapPoolMap.stageId"])
|
||||
.where("tournamentTeamId", "=", tournamentTeamId)
|
||||
.execute();
|
||||
|
||||
const newTeam = await trx
|
||||
.insertInto("TournamentTeam")
|
||||
.values({
|
||||
...oldTeam,
|
||||
tournamentId: destinationTournamentId,
|
||||
inviteCode: nanoid(INVITE_CODE_LENGTH),
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await trx
|
||||
.insertInto("TournamentTeamMember")
|
||||
.values(
|
||||
oldMembers.map((member) => ({
|
||||
...member,
|
||||
tournamentTeamId: newTeam.id,
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
|
||||
if (oldMapPool.length > 0) {
|
||||
await trx
|
||||
.insertInto("MapPoolMap")
|
||||
.values(
|
||||
oldMapPool.map((mapPoolMap) => ({
|
||||
...mapPoolMap,
|
||||
tournamentTeamId: newTeam.id,
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function update({
|
||||
team,
|
||||
avatarFileName,
|
||||
|
|
|
|||
|
|
@ -255,6 +255,10 @@ export const action: ActionFunction = async ({ request, params }) => {
|
|||
case "UNREGISTER": {
|
||||
validate(ownTeam, "You are not registered to this tournament");
|
||||
validate(!ownTeamCheckedIn, "You cannot unregister after checking in");
|
||||
validate(
|
||||
!tournament.isLeagueSignup || tournament.registrationOpen,
|
||||
"Unregistering from leagues is not possible after registration has closed",
|
||||
);
|
||||
|
||||
deleteTeam(ownTeam.id);
|
||||
|
||||
|
|
|
|||
12
app/features/tournament/routes/luti.tsx
Normal file
12
app/features/tournament/routes/luti.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { redirect } from "@remix-run/node";
|
||||
import { notFoundIfFalsy } from "../../../utils/remix.server";
|
||||
import { tournamentPage } from "../../../utils/urls";
|
||||
import { LEAGUES } from "../tournament-constants";
|
||||
|
||||
const maybeLatest = LEAGUES.LUTI?.at(-1);
|
||||
|
||||
export const loader = () => {
|
||||
const latest = notFoundIfFalsy(maybeLatest);
|
||||
|
||||
return redirect(tournamentPage(latest.tournamentId));
|
||||
};
|
||||
|
|
@ -23,6 +23,7 @@ import invariant from "~/utils/invariant";
|
|||
import { assertUnreachable } from "~/utils/types";
|
||||
import {
|
||||
calendarEventPage,
|
||||
teamPage,
|
||||
tournamentEditPage,
|
||||
tournamentPage,
|
||||
} from "~/utils/urls";
|
||||
|
|
@ -62,22 +63,24 @@ export default function TournamentAdminPage() {
|
|||
>
|
||||
Edit event info
|
||||
</LinkButton>
|
||||
<FormWithConfirm
|
||||
dialogHeading={t("calendar:actions.delete.confirm", {
|
||||
name: tournament.ctx.name,
|
||||
})}
|
||||
action={calendarEventPage(tournament.ctx.eventId)}
|
||||
submitButtonTestId="delete-submit-button"
|
||||
>
|
||||
<Button
|
||||
className="ml-auto"
|
||||
size="tiny"
|
||||
variant="minimal-destructive"
|
||||
type="submit"
|
||||
{!tournament.isLeagueSignup ? (
|
||||
<FormWithConfirm
|
||||
dialogHeading={t("calendar:actions.delete.confirm", {
|
||||
name: tournament.ctx.name,
|
||||
})}
|
||||
action={calendarEventPage(tournament.ctx.eventId)}
|
||||
submitButtonTestId="delete-submit-button"
|
||||
>
|
||||
{t("calendar:actions.delete")}
|
||||
</Button>
|
||||
</FormWithConfirm>
|
||||
<Button
|
||||
className="ml-auto"
|
||||
size="tiny"
|
||||
variant="minimal-destructive"
|
||||
type="submit"
|
||||
>
|
||||
{t("calendar:actions.delete")}
|
||||
</Button>
|
||||
</FormWithConfirm>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{tournament.isAdmin(user) &&
|
||||
|
|
@ -111,8 +114,12 @@ export default function TournamentAdminPage() {
|
|||
<CastTwitchAccounts />
|
||||
<Divider smallText>Participant list download</Divider>
|
||||
<DownloadParticipants />
|
||||
<Divider smallText>Bracket reset</Divider>
|
||||
<BracketReset />
|
||||
{!tournament.isLeagueSignup ? (
|
||||
<>
|
||||
<Divider smallText>Bracket reset</Divider>
|
||||
<BracketReset />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -609,6 +616,35 @@ function DownloadParticipants() {
|
|||
.join("\n");
|
||||
}
|
||||
|
||||
function leagueFormat() {
|
||||
const memberColumnsCount = tournament.ctx.teams.reduce(
|
||||
(max, team) => Math.max(max, team.members.length),
|
||||
0,
|
||||
);
|
||||
const header = `Team id,Team name,Team page URL,Div${Array.from({
|
||||
length: memberColumnsCount,
|
||||
})
|
||||
.map((_, i) => `,Member ${i + 1} name,Member${i + 1} URL`)
|
||||
.join("")}`;
|
||||
|
||||
return `${header}\n${tournament.ctx.teams
|
||||
.map((team) => {
|
||||
return `${team.id},${team.name},${team.team ? teamPage(team.team.customUrl) : ""},,${team.members
|
||||
.map(
|
||||
(member) =>
|
||||
`${member.username},https://sendou.ink/u/${member.discordId}`,
|
||||
)
|
||||
.join(",")}${Array(
|
||||
memberColumnsCount - team.members.length === 0
|
||||
? 0
|
||||
: memberColumnsCount - team.members.length + 1,
|
||||
)
|
||||
.fill(",")
|
||||
.join("")}`;
|
||||
})
|
||||
.join("\n")}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="stack horizontal sm flex-wrap">
|
||||
|
|
@ -656,6 +692,19 @@ function DownloadParticipants() {
|
|||
>
|
||||
Simple list in seeded order
|
||||
</Button>
|
||||
{tournament.isLeagueSignup ? (
|
||||
<Button
|
||||
size="tiny"
|
||||
onClick={() =>
|
||||
handleDownload({
|
||||
filename: "league-format.csv",
|
||||
content: leagueFormat(),
|
||||
})
|
||||
}
|
||||
>
|
||||
League format
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -119,20 +119,22 @@ export default function TournamentRegisterPage() {
|
|||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="tournament__by mt-2">
|
||||
<div className="stack horizontal xs items-center">
|
||||
<ClockIcon className="tournament__info__icon" />{" "}
|
||||
{isMounted
|
||||
? tournament.ctx.startTime.toLocaleString(i18n.language, {
|
||||
timeZoneName: "short",
|
||||
minute: startsAtEvenHour ? undefined : "numeric",
|
||||
hour: "numeric",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
})
|
||||
: null}
|
||||
{!tournament.isLeagueSignup ? (
|
||||
<div className="tournament__by mt-2">
|
||||
<div className="stack horizontal xs items-center">
|
||||
<ClockIcon className="tournament__info__icon" />{" "}
|
||||
{isMounted
|
||||
? tournament.ctx.startTime.toLocaleString(i18n.language, {
|
||||
timeZoneName: "short",
|
||||
minute: startsAtEvenHour ? undefined : "numeric",
|
||||
hour: "numeric",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="stack horizontal sm mt-1">
|
||||
{tournament.ranked ? (
|
||||
<div className="tournament__badge tournament__badge__ranked">
|
||||
|
|
@ -411,10 +413,12 @@ function RegistrationProgress({
|
|||
completed: mapPool && mapPool.length > 0,
|
||||
}
|
||||
: null,
|
||||
{
|
||||
name: t("tournament:pre.steps.check-in"),
|
||||
completed: checkedIn,
|
||||
},
|
||||
!tournament.isLeagueSignup
|
||||
? {
|
||||
name: t("tournament:pre.steps.check-in"),
|
||||
completed: checkedIn,
|
||||
}
|
||||
: null,
|
||||
]);
|
||||
|
||||
const regClosesBeforeStart =
|
||||
|
|
@ -422,7 +426,10 @@ function RegistrationProgress({
|
|||
tournament.ctx.startTime.getTime();
|
||||
|
||||
const registrationClosesAtString = isMounted
|
||||
? tournament.registrationClosesAt.toLocaleTimeString(i18n.language, {
|
||||
? (tournament.isLeagueSignup
|
||||
? tournament.ctx.startTime
|
||||
: tournament.registrationClosesAt
|
||||
).toLocaleTimeString(i18n.language, {
|
||||
minute: "numeric",
|
||||
hour: "numeric",
|
||||
day: "2-digit",
|
||||
|
|
@ -456,22 +463,24 @@ function RegistrationProgress({
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
<CheckIn
|
||||
canCheckIn={steps.filter((step) => !step.completed).length === 1}
|
||||
status={
|
||||
tournament.regularCheckInIsOpen
|
||||
? "OPEN"
|
||||
: tournament.regularCheckInHasEnded
|
||||
? "OVER"
|
||||
: "UPCOMING"
|
||||
}
|
||||
startDate={tournament.regularCheckInStartsAt}
|
||||
endDate={tournament.regularCheckInEndsAt}
|
||||
checkedIn={checkedIn}
|
||||
/>
|
||||
{!tournament.isLeagueSignup ? (
|
||||
<CheckIn
|
||||
canCheckIn={steps.filter((step) => !step.completed).length === 1}
|
||||
status={
|
||||
tournament.regularCheckInIsOpen
|
||||
? "OPEN"
|
||||
: tournament.regularCheckInHasEnded
|
||||
? "OVER"
|
||||
: "UPCOMING"
|
||||
}
|
||||
startDate={tournament.regularCheckInStartsAt}
|
||||
endDate={tournament.regularCheckInEndsAt}
|
||||
checkedIn={checkedIn}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
<div className="tournament__section__warning">
|
||||
{regClosesBeforeStart ? (
|
||||
{regClosesBeforeStart || tournament.isLeagueSignup ? (
|
||||
<span className="text-warning">
|
||||
Registration closes at {registrationClosesAtString}
|
||||
</span>
|
||||
|
|
@ -672,6 +681,17 @@ function TeamInfo({
|
|||
</Button>
|
||||
</FormWithConfirm>
|
||||
) : null}
|
||||
{canUnregister &&
|
||||
tournament.isLeagueSignup &&
|
||||
!tournament.registrationOpen ? (
|
||||
<Popover
|
||||
triggerClassName="minimal-destructive tiny build__small-text"
|
||||
buttonChildren={t("tournament:pre.info.unregister")}
|
||||
>
|
||||
Unregistration from a league after the registration has ended is
|
||||
handled by the organizers
|
||||
</Popover>
|
||||
) : null}
|
||||
</div>
|
||||
<section className="tournament__section">
|
||||
<Form method="post" className="stack md items-center" ref={ref}>
|
||||
|
|
|
|||
|
|
@ -25,8 +25,10 @@ import type { SendouRouteHandle } from "~/utils/remix.server";
|
|||
import { makeTitle } from "~/utils/strings";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import {
|
||||
tournamentDivisionsPage,
|
||||
tournamentOrganizationPage,
|
||||
tournamentPage,
|
||||
tournamentRegisterPage,
|
||||
userSubmittedImage,
|
||||
} from "~/utils/urls";
|
||||
import { streamsByTournamentId } from "../core/streams.server";
|
||||
|
|
@ -245,12 +247,37 @@ export function TournamentLayout() {
|
|||
return (
|
||||
<Main bigger>
|
||||
<SubNav>
|
||||
<SubNavLink to="register" data-testid="register-tab" prefetch="intent">
|
||||
{tournament.hasStarted ? "Info" : t("tournament:tabs.register")}
|
||||
</SubNavLink>
|
||||
<SubNavLink to="brackets" data-testid="brackets-tab" prefetch="render">
|
||||
{t("tournament:tabs.brackets")}
|
||||
<SubNavLink
|
||||
to={tournamentRegisterPage(
|
||||
tournament.isLeagueDivision
|
||||
? tournament.ctx.parentTournamentId!
|
||||
: tournament.ctx.id,
|
||||
)}
|
||||
data-testid="register-tab"
|
||||
prefetch="intent"
|
||||
>
|
||||
{tournament.hasStarted || tournament.isLeagueDivision
|
||||
? "Info"
|
||||
: t("tournament:tabs.register")}
|
||||
</SubNavLink>
|
||||
{!tournament.isLeagueSignup ? (
|
||||
<SubNavLink
|
||||
to="brackets"
|
||||
data-testid="brackets-tab"
|
||||
prefetch="render"
|
||||
>
|
||||
{t("tournament:tabs.brackets")}
|
||||
</SubNavLink>
|
||||
) : null}
|
||||
{tournament.isLeagueSignup || tournament.isLeagueDivision ? (
|
||||
<SubNavLink
|
||||
to={tournamentDivisionsPage(
|
||||
tournament.ctx.parentTournamentId ?? tournament.ctx.id,
|
||||
)}
|
||||
>
|
||||
Divisions
|
||||
</SubNavLink>
|
||||
) : null}
|
||||
<SubNavLink
|
||||
to="teams"
|
||||
end={false}
|
||||
|
|
@ -276,9 +303,11 @@ export function TournamentLayout() {
|
|||
{t("tournament:tabs.results")}
|
||||
</SubNavLink>
|
||||
) : null}
|
||||
{tournament.isOrganizer(user) && !tournament.hasStarted && (
|
||||
<SubNavLink to="seeds">{t("tournament:tabs.seeds")}</SubNavLink>
|
||||
)}
|
||||
{tournament.isOrganizer(user) &&
|
||||
!tournament.hasStarted &&
|
||||
!tournament.isLeagueSignup && (
|
||||
<SubNavLink to="seeds">{t("tournament:tabs.seeds")}</SubNavLink>
|
||||
)}
|
||||
{tournament.isOrganizer(user) && !tournament.everyBracketOver && (
|
||||
<SubNavLink to="admin" data-testid="admin-tab">
|
||||
{t("tournament:tabs.admin")}
|
||||
|
|
|
|||
|
|
@ -14,3 +14,28 @@ export const TOURNAMENT = {
|
|||
SWISS_DEFAULT_GROUP_COUNT: 1,
|
||||
SWISS_DEFAULT_ROUND_COUNT: 5,
|
||||
} as const;
|
||||
|
||||
export const LEAGUES =
|
||||
process.env.NODE_ENV === "development"
|
||||
? {
|
||||
LUTI: [
|
||||
{
|
||||
tournamentId: 6,
|
||||
weeks: [
|
||||
{
|
||||
weekNumber: 2,
|
||||
year: 2025,
|
||||
},
|
||||
{
|
||||
weekNumber: 3,
|
||||
year: 2025,
|
||||
},
|
||||
{
|
||||
weekNumber: 4,
|
||||
year: 2025,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
: {};
|
||||
|
|
|
|||
|
|
@ -2,14 +2,16 @@ import type { Params } from "@remix-run/react";
|
|||
import type { Tournament } from "~/db/types";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists";
|
||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import { weekNumberToDate } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { tournamentLogoUrl } from "~/utils/urls";
|
||||
import { MapPool } from "../map-list-generator/core/map-pool";
|
||||
import { currentSeason } from "../mmr/season";
|
||||
import { BANNED_MAPS } from "../sendouq-settings/banned-maps";
|
||||
import type { Tournament as TournamentClass } from "../tournament-bracket/core/Tournament";
|
||||
import type { TournamentData } from "../tournament-bracket/core/Tournament.server";
|
||||
import type { PlayedSet } from "./core/sets.server";
|
||||
import { TOURNAMENT } from "./tournament-constants";
|
||||
import { LEAGUES, TOURNAMENT } from "./tournament-constants";
|
||||
|
||||
export function tournamentIdFromParams(params: Params<string>) {
|
||||
const result = Number(params.id);
|
||||
|
|
@ -269,3 +271,39 @@ export function tournamentIsRanked({
|
|||
|
||||
return isSetAsRanked ?? true;
|
||||
}
|
||||
|
||||
export function resolveLeagueRoundStartDate(
|
||||
tournament: TournamentClass,
|
||||
roundId: number,
|
||||
) {
|
||||
if (!tournament.isLeagueDivision) return null;
|
||||
|
||||
const league = Object.values(LEAGUES)
|
||||
.flat()
|
||||
.find(
|
||||
(league) => league.tournamentId === tournament.ctx.parentTournamentId,
|
||||
);
|
||||
if (!league) return null;
|
||||
|
||||
const bracket = tournament.brackets.find((b) =>
|
||||
b.data.round.some((r) => r.id === roundId),
|
||||
);
|
||||
|
||||
const round = bracket?.data.round.find((r) => r.id === roundId);
|
||||
const onlyRelevantRounds = bracket?.data.round.filter(
|
||||
(r) => r.group_id === round?.group_id,
|
||||
);
|
||||
|
||||
const roundIdx = onlyRelevantRounds?.findIndex((r) => r.id === roundId);
|
||||
if (roundIdx === undefined) return null;
|
||||
|
||||
const week = league.weeks[roundIdx];
|
||||
if (!week) return null;
|
||||
|
||||
const date = weekNumberToDate({
|
||||
week: week.weekNumber,
|
||||
year: week.year,
|
||||
});
|
||||
|
||||
return date;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -651,3 +651,39 @@
|
|||
background-color: var(--theme-transparent);
|
||||
border-radius: var(--rounded);
|
||||
}
|
||||
|
||||
.tournament__div__link {
|
||||
background-color: var(--bg-lighter-solid);
|
||||
padding: var(--s-2) var(--s-4);
|
||||
border-radius: var(--rounded);
|
||||
width: max-content;
|
||||
font-size: var(--fonts-lg);
|
||||
}
|
||||
|
||||
.tournament__div__link:focus-visible {
|
||||
outline: 3px solid var(--theme);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.tournament__div__link__participant {
|
||||
outline: 3px solid var(--bg-lightest-solid);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.tournament__div__link__participant svg {
|
||||
fill: var(--theme);
|
||||
}
|
||||
|
||||
.tournament__div__participant-counts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-sm);
|
||||
gap: var(--s-2);
|
||||
margin-block-end: var(--s-1);
|
||||
}
|
||||
|
||||
.tournament__div__participant-counts > svg {
|
||||
width: 1rem;
|
||||
margin-block-end: 1px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,6 +79,10 @@ export default [
|
|||
route("subs/new", "features/tournament-subs/routes/to.$id.subs.new.tsx"),
|
||||
|
||||
route("brackets", "features/tournament-bracket/routes/to.$id.brackets.tsx"),
|
||||
route(
|
||||
"divisions",
|
||||
"features/tournament-bracket/routes/to.$id.divisions.tsx",
|
||||
),
|
||||
route(
|
||||
"brackets/subscribe",
|
||||
"features/tournament-bracket/routes/to.$id.brackets.subscribe.tsx",
|
||||
|
|
@ -92,6 +96,7 @@ export default [
|
|||
"features/tournament-bracket/routes/to.$id.matches.$mid.subscribe.tsx",
|
||||
),
|
||||
]),
|
||||
route("luti", "features/tournament/routes/luti.tsx"),
|
||||
|
||||
...prefix("/org/:slug", [
|
||||
index("features/tournament-organization/routes/org.$slug.tsx"),
|
||||
|
|
|
|||
|
|
@ -347,3 +347,14 @@
|
|||
gap: var(--s-1);
|
||||
padding: var(--s-0-5) var(--s-1-5);
|
||||
}
|
||||
|
||||
.front__luti-banner {
|
||||
background-color: #4874a0;
|
||||
color: #fff;
|
||||
border-radius: var(--rounded);
|
||||
padding: var(--s-2);
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import type { Params, UIMatch } from "@remix-run/react";
|
|||
import type { Namespace, TFunction } from "i18next";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import type navItems from "~/components/layout/nav-items.json";
|
||||
import type { navItems } from "~/components/layout/nav-items";
|
||||
import { s3UploadHandler } from "~/features/img-upload";
|
||||
import invariant from "./invariant";
|
||||
|
||||
|
|
@ -247,7 +247,7 @@ export type SendouRouteHandle = {
|
|||
t: TFunction<"common", undefined>;
|
||||
}) => Breadcrumb | Array<Breadcrumb> | undefined;
|
||||
|
||||
/** The name of a navItem that is active on this route. See nav-items.json */
|
||||
/** The name of a navItem that is active on this route. See nav-items.ts */
|
||||
navItemName?: (typeof navItems)[number]["name"];
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import slugify from "slugify";
|
||||
import type navItems from "~/components/layout/nav-items.json";
|
||||
import type { Preference } from "~/db/tables";
|
||||
import type {
|
||||
Art,
|
||||
|
|
@ -135,6 +134,7 @@ export const TIERS_PAGE = "/tiers";
|
|||
export const SUSPENDED_PAGE = "/suspended";
|
||||
export const LFG_PAGE = "/lfg";
|
||||
export const SETTINGS_PAGE = "/settings";
|
||||
export const LUTI_PAGE = "/luti";
|
||||
|
||||
export const BLANK_IMAGE_URL = "/static-assets/img/blank.gif";
|
||||
export const COMMON_PREVIEW_IMAGE =
|
||||
|
|
@ -314,6 +314,8 @@ export const tournamentBracketsPage = ({
|
|||
query.size > 0 ? `?${query.toString()}` : ""
|
||||
}`;
|
||||
};
|
||||
export const tournamentDivisionsPage = (tournamentId: number) =>
|
||||
`/to/${tournamentId}/divisions`;
|
||||
export const tournamentResultsPage = (tournamentId: number) =>
|
||||
`/to/${tournamentId}/results`;
|
||||
export const tournamentBracketsSubscribePage = (tournamentId: number) =>
|
||||
|
|
@ -420,7 +422,7 @@ export const badgeUrl = ({
|
|||
export const articlePreviewUrl = (slug: string) =>
|
||||
`/static-assets/img/article-previews/${slug}.png`;
|
||||
|
||||
export const navIconUrl = (navItem: (typeof navItems)[number]["name"]) =>
|
||||
export const navIconUrl = (navItem: string) =>
|
||||
`/static-assets/img/layout/${navItem}`;
|
||||
export const gearImageUrl = (gearType: GearType, gearSplId: number) =>
|
||||
`/static-assets/img/gear/${gearType.toLowerCase()}/${gearSplId}`;
|
||||
|
|
|
|||
14
docs/tournament-leagues.md
Normal file
14
docs/tournament-leagues.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
Leagues are a variety of sendou.ink tournaments where each division is a separate competition. Participants sign-up to one "entry tournament" then get divided into divisions by the organizers and one winner emerges per division.
|
||||
|
||||
## Creating a league
|
||||
|
||||
Note: leagues are not an open feature available for everyone and require some amount of manual admin work from Sendou.
|
||||
|
||||
1) Create tournament as normal on sendou.ink. This will be the tournament where users sign up to. Set registration closing time as you wish.
|
||||
2) Link the bracket to sendou.
|
||||
3) Once the registration closes from the admin tab download participant list as "league format" (.csv file).
|
||||
4) Order the teams as you wish. Fill the "div" column with the desired division per participant.
|
||||
5) Give sendou back the .csv file.
|
||||
6) Division brackets will be generated. You get the chance to edit each as you wish before starting it. If you want to use the same maps for each division, just do them for one bracket then let Sendou know. There is an admin script that can be ran that copies them across all divisions.
|
||||
7) (Optional): let Sendou know the start time of each bracket to control during which week which round of groups is played.
|
||||
8) When everything looks good start divisions and play out the tournament as normal.
|
||||
|
|
@ -24,6 +24,7 @@
|
|||
"pages.sendouq": "SendouQ",
|
||||
"pages.lfg": "LFG",
|
||||
"pages.settings": "Settings",
|
||||
"pages.luti": "LUTI",
|
||||
|
||||
"header.profile": "Profile",
|
||||
"header.logout": "Log out",
|
||||
|
|
|
|||
11
migrations/078-parent-tournament-id.js
Normal file
11
migrations/078-parent-tournament-id.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export function up(db) {
|
||||
db.transaction(() => {
|
||||
db.prepare(
|
||||
/* sql */ `alter table "Tournament" add "parentTournamentId" integer references "Tournament"("id") on delete restrict`,
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
/* sql */ `alter table "CalendarEvent" add "hidden" integer default 0`,
|
||||
).run();
|
||||
})();
|
||||
}
|
||||
BIN
public/static-assets/img/layout/luti.avif
Normal file
BIN
public/static-assets/img/layout/luti.avif
Normal file
Binary file not shown.
BIN
public/static-assets/img/layout/luti.png
Normal file
BIN
public/static-assets/img/layout/luti.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
184
scripts/create-league-divisions.ts
Normal file
184
scripts/create-league-divisions.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
// for testing use the command `npx tsx ./scripts/create-league-divisions.ts 6 'https://gist.githubusercontent.com/Sendouc/38aa4d5d8426035ce178c09598ae627f/raw/17be9bb53a9f017c2097d0624f365d1c5a029f01/league.csv'`
|
||||
|
||||
import "dotenv/config";
|
||||
import { z } from "zod";
|
||||
import { ADMIN_ID } from "~/constants";
|
||||
import { db } from "~/db/sql";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import type { Tournament } from "~/features/tournament-bracket/core/Tournament";
|
||||
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
|
||||
const tournamentId = Number(process.argv[2]?.trim());
|
||||
|
||||
invariant(
|
||||
tournamentId && !Number.isNaN(tournamentId),
|
||||
"tournament id is required (argument 1)",
|
||||
);
|
||||
|
||||
const csvUrl = process.argv[3]?.trim();
|
||||
|
||||
invariant(z.string().url().parse(csvUrl), "csv url is required (argument 2)");
|
||||
|
||||
async function main() {
|
||||
console.time("create-league-divisions");
|
||||
|
||||
const tournament = await tournamentFromDB({
|
||||
tournamentId,
|
||||
user: { id: ADMIN_ID },
|
||||
});
|
||||
invariant(tournament.isLeagueSignup, "Tournament is not a league signup");
|
||||
|
||||
const csv = await loadCsv();
|
||||
|
||||
const teams = parseCsv(csv);
|
||||
for (const team of teams) {
|
||||
validateTeam(team, tournament);
|
||||
}
|
||||
validateDivs(teams);
|
||||
|
||||
const grouped = Object.entries(Object.groupBy(teams, (t) => t.division)).sort(
|
||||
(a, b) => {
|
||||
const divAIndex = teams.findIndex((t) => t.division === a[0]);
|
||||
const divBIndex = teams.findIndex((t) => t.division === b[0]);
|
||||
|
||||
return divAIndex - divBIndex;
|
||||
},
|
||||
);
|
||||
|
||||
for (const [, divsTeams] of grouped) {
|
||||
divsTeams!.sort((a, b) => {
|
||||
const teamAIndex = teams.findIndex((t) => t.id === a.id);
|
||||
const teamBIndex = teams.findIndex((t) => t.id === b.id);
|
||||
|
||||
return teamAIndex - teamBIndex;
|
||||
});
|
||||
}
|
||||
|
||||
const calendarEvent = await db
|
||||
.selectFrom("CalendarEvent")
|
||||
.selectAll()
|
||||
.where("CalendarEvent.id", "=", tournament.ctx.eventId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
for (const [div, divsTeams] of grouped) {
|
||||
logger.info(`Creating division ${div}...`);
|
||||
|
||||
const createdEvent = await CalendarRepository.create({
|
||||
parentTournamentId: tournament.ctx.id,
|
||||
authorId: tournament.ctx.author.id,
|
||||
bracketProgression: tournament.ctx.settings.bracketProgression,
|
||||
description: tournament.ctx.description,
|
||||
deadlines: tournament.ctx.settings.deadlines,
|
||||
discordInviteCode:
|
||||
tournament.ctx.discordUrl?.replace("https://discord.gg/", "") ?? null,
|
||||
mapPickingStyle: tournament.ctx.mapPickingStyle,
|
||||
name: `${tournament.ctx.name} - Division ${div}`,
|
||||
organizationId: tournament.ctx.organization?.id ?? null,
|
||||
rules: tournament.ctx.rules,
|
||||
startTimes: [dateToDatabaseTimestamp(tournament.ctx.startTime)],
|
||||
tags: null,
|
||||
tournamentToCopyId: tournament.ctx.id,
|
||||
avatarImgId: calendarEvent.avatarImgId ?? undefined,
|
||||
avatarFileName: undefined,
|
||||
mapPoolMaps:
|
||||
tournament.ctx.mapPickingStyle !== "TO"
|
||||
? tournament.ctx.tieBreakerMapPool
|
||||
: tournament.ctx.toSetMapPool,
|
||||
badges: [],
|
||||
enableNoScreenToggle: tournament.ctx.settings.enableNoScreenToggle,
|
||||
enableSubs: false,
|
||||
isInvitational: true,
|
||||
autonomousSubs: false,
|
||||
isRanked: tournament.ctx.settings.isRanked,
|
||||
minMembersPerTeam: tournament.ctx.settings.minMembersPerTeam,
|
||||
regClosesAt: tournament.ctx.settings.regClosesAt,
|
||||
requireInGameNames: tournament.ctx.settings.requireInGameNames,
|
||||
bracketUrl: "https://sendou.ink",
|
||||
isFullTournament: true,
|
||||
autoValidateAvatar: true,
|
||||
// these come from progression
|
||||
swissGroupCount: undefined,
|
||||
swissRoundCount: undefined,
|
||||
teamsPerGroup: undefined,
|
||||
thirdPlaceMatch: undefined,
|
||||
});
|
||||
|
||||
for (const team of divsTeams!) {
|
||||
await TournamentTeamRepository.copyFromAnotherTournament({
|
||||
destinationTournamentId: createdEvent.tournamentId!,
|
||||
tournamentTeamId: team.id,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`Created division ${div} (id: ${createdEvent.tournamentId})`);
|
||||
}
|
||||
|
||||
console.timeEnd("create-league-divisions");
|
||||
}
|
||||
|
||||
async function loadCsv() {
|
||||
const response = await fetch(csvUrl);
|
||||
return response.text();
|
||||
}
|
||||
|
||||
const csvSchema = z.array(
|
||||
z.object({
|
||||
"Team id": z.coerce.number(),
|
||||
Div: z.string(),
|
||||
}),
|
||||
);
|
||||
|
||||
type ParsedTeam = ReturnType<typeof parseCsv>[number];
|
||||
|
||||
function parseCsv(csv: string) {
|
||||
const lines = csv.split("\n");
|
||||
const headers = lines[0].split(",");
|
||||
const rows = lines.slice(1).map((line) => {
|
||||
const row = line.split(",");
|
||||
return headers.reduce(
|
||||
(acc, header, i) => {
|
||||
acc[header] = row[i];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
});
|
||||
|
||||
const validated = csvSchema.parse(rows);
|
||||
|
||||
return validated.map((row) => ({
|
||||
id: row["Team id"],
|
||||
division: row.Div,
|
||||
}));
|
||||
}
|
||||
|
||||
function validateTeam(team: ParsedTeam, tournament: Tournament) {
|
||||
invariant(
|
||||
tournament.ctx.teams.some((t) => t.id === team.id),
|
||||
`Team with id ${team.id} not found in tournament`,
|
||||
);
|
||||
}
|
||||
|
||||
const MIN_TEAMS_COUNT_PER_DIV = 6;
|
||||
function validateDivs(teams: ParsedTeam[]) {
|
||||
const counts = teams.reduce(
|
||||
(acc, team) => {
|
||||
acc[team.division] = (acc[team.division] ?? 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
for (const [div, count] of Object.entries(counts)) {
|
||||
invariant(
|
||||
count >= MIN_TEAMS_COUNT_PER_DIV,
|
||||
`Division ${div} has ${count} teams, expected at least ${MIN_TEAMS_COUNT_PER_DIV}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Loading…
Reference in New Issue
Block a user