TO Tools back (#1349)

* Remove friend code

* Revive TO Tools admin page

* Revive TO Tools maps page

* Initial one mode only map list

* Add modesIncluded arg

* Handle no maps picked for SZ only generation

* Tiebreaker is always from the maps of the teams

* Make modesIncluded necessary arg

* Tiebreaker is from neither team's pool if no overlap

* Handles worst case duplication

* Handles one team submitted no maps test

* Fix crash

* Seed

* Can change one mode tournament map pool

* Fix join page link

* Remove useless TODO

* Fixes related to mapListGeneratorAvailable

* Fix map list generation considering impossible map lists making it take forever

* Show unlisted select for both sides

* Add info texts

* Remove register button

* Add todos

* Finished version for ITZ

* Times

* Remove TODOs

* 23->24
This commit is contained in:
Kalle
2023-04-22 11:44:20 +03:00
committed by GitHub
parent 40e56d2b53
commit 5e36b76ee8
33 changed files with 1403 additions and 269 deletions

View File

@@ -0,0 +1,16 @@
export function UserIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<path
fillRule="evenodd"
d="M7.5 6a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM3.751 20.105a8.25 8.25 0 0116.498 0 .75.75 0 01-.437.695A18.683 18.683 0 0112 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 01-.437-.695z"
clipRule="evenodd"
/>
</svg>
);
}

View File

@@ -6,7 +6,8 @@ insert into
"description",
"discordInviteCode",
"bracketUrl",
"toToolsEnabled"
"toToolsEnabled",
"toToolsMode"
)
values
(
@@ -16,5 +17,6 @@ values
@description,
@discordInviteCode,
@bracketUrl,
@toToolsEnabled
@toToolsEnabled,
@toToolsMode
) returning *

View File

@@ -66,6 +66,7 @@ export type CreateArgs = Pick<
| "discordInviteCode"
| "bracketUrl"
| "toToolsEnabled"
| "toToolsMode"
> & {
startTimes: Array<CalendarEventDate["startTime"]>;
badges: Array<CalendarEventBadge["badgeId"]>;

View File

@@ -6,6 +6,7 @@ set
"description" = @description,
"discordInviteCode" = @discordInviteCode,
"bracketUrl" = @bracketUrl,
"toToolsEnabled" = @toToolsEnabled
"toToolsEnabled" = @toToolsEnabled,
"toToolsMode" = @toToolsMode
where
"id" = @eventId

View File

@@ -39,6 +39,10 @@ const NZAP_TEST_ID = 2;
const AMOUNT_OF_CALENDAR_EVENTS = 200;
const calendarEventWithToToolsSz = () => calendarEventWithToTools(true);
const calendarEventWithToToolsTeamsSz = () =>
calendarEventWithToToolsTeams(true);
const basicSeeds = [
adminUser,
makeAdminPatron,
@@ -61,6 +65,8 @@ const basicSeeds = [
calendarEventWithToTools,
calendarEventWithToToolsTieBreakerMapPool,
calendarEventWithToToolsTeams,
calendarEventWithToToolsSz,
calendarEventWithToToolsTeamsSz,
adminBuilds,
manySplattershotBuilds,
detailedTeam,
@@ -603,7 +609,9 @@ function calendarEventResults() {
}
const TO_TOOLS_CALENDAR_EVENT_ID = 201;
function calendarEventWithToTools() {
function calendarEventWithToTools(sz?: boolean) {
const eventId = TO_TOOLS_CALENDAR_EVENT_ID + (sz ? 1 : 0);
sql
.prepare(
`
@@ -614,7 +622,8 @@ function calendarEventWithToTools() {
"discordInviteCode",
"bracketUrl",
"authorId",
"toToolsEnabled"
"toToolsEnabled",
"toToolsMode"
) values (
$id,
$name,
@@ -622,18 +631,20 @@ function calendarEventWithToTools() {
$discordInviteCode,
$bracketUrl,
$authorId,
$toToolsEnabled
$toToolsEnabled,
$toToolsMode
)
`
)
.run({
id: TO_TOOLS_CALENDAR_EVENT_ID,
name: "PICNIC #2",
id: eventId,
name: sz ? "In The Zone 22" : "PICNIC #2",
description: faker.lorem.paragraph(),
discordInviteCode: faker.lorem.word(),
bracketUrl: faker.internet.url(),
authorId: 1,
toToolsEnabled: 1,
toToolsMode: sz ? "SZ" : null,
});
sql
@@ -649,7 +660,7 @@ function calendarEventWithToTools() {
`
)
.run({
eventId: TO_TOOLS_CALENDAR_EVENT_ID,
eventId,
startTime: dateToDatabaseTimestamp(new Date()),
});
}
@@ -693,7 +704,7 @@ const availablePairs = rankedModesShort
availableStages.map((stageId) => ({ mode, stageId: stageId }))
)
.filter((pair) => !tiebreakerPicks.has(pair));
function calendarEventWithToToolsTeams() {
function calendarEventWithToToolsTeams(sz?: boolean) {
const userIds = userIdsInRandomOrder(true);
for (let id = 1; id <= 40; id++) {
sql
@@ -715,10 +726,10 @@ function calendarEventWithToToolsTeams() {
`
)
.run({
id,
id: id + (sz ? 100 : 0),
name: names.pop(),
createdAt: dateToDatabaseTimestamp(new Date()),
calendarEventId: TO_TOOLS_CALENDAR_EVENT_ID,
calendarEventId: TO_TOOLS_CALENDAR_EVENT_ID + (sz ? 1 : 0),
inviteCode: nanoid(INVITE_CODE_LENGTH),
});
@@ -744,7 +755,7 @@ function calendarEventWithToToolsTeams() {
`
)
.run({
tournamentTeamId: id,
tournamentTeamId: id + (sz ? 100 : 0),
userId: userIds.pop()!,
isOwner: i === 0 ? 1 : 0,
createdAt: dateToDatabaseTimestamp(new Date()),
@@ -761,12 +772,14 @@ function calendarEventWithToToolsTeams() {
const stageUsedCounts: Partial<Record<StageId, number>> = {};
for (const pair of shuffledPairs) {
if (pair.mode === "SZ" && SZ >= 2) continue;
if (sz && pair.mode !== "SZ") continue;
if (pair.mode === "SZ" && SZ >= (sz ? 6 : 2)) continue;
if (pair.mode === "TC" && TC >= 2) continue;
if (pair.mode === "RM" && RM >= 2) continue;
if (pair.mode === "CB" && CB >= 2) continue;
if (stageUsedCounts[pair.stageId] === 2) continue;
if (stageUsedCounts[pair.stageId] === (sz ? 1 : 2)) continue;
stageUsedCounts[pair.stageId] =
(stageUsedCounts[pair.stageId] ?? 0) + 1;
@@ -786,7 +799,7 @@ function calendarEventWithToToolsTeams() {
`
)
.run({
tournamentTeamId: id,
tournamentTeamId: id + (sz ? 100 : 0),
stageId: pair.stageId,
mode: pair.mode,
});

View File

@@ -117,6 +117,8 @@ export interface CalendarEvent {
customUrl: string | null;
/** Is tournament tools page visible */
toToolsEnabled: number;
toToolsMode: RankedModeShort | null;
isBeforeStart: number;
}
export type CalendarEventTag = keyof typeof allTags;
@@ -182,8 +184,8 @@ export interface MapPoolMap {
export interface TournamentTeam {
id: number;
name: string | null;
friendCode: string | null;
// TODO: make non-nullable in database as well
name: string;
createdAt: number;
seed: number | null;
calendarEventId: number;

View File

@@ -6,10 +6,12 @@ import { INVITE_CODE_LENGTH } from "~/constants";
const createTeamStm = sql.prepare(/*sql*/ `
insert into "TournamentTeam" (
"calendarEventId",
"inviteCode"
"inviteCode",
"name"
) values (
@calendarEventId,
@inviteCode
@inviteCode,
@name
) returning *
`);
@@ -28,13 +30,16 @@ const createMemberStm = sql.prepare(/*sql*/ `
export const createTeam = sql.transaction(
({
calendarEventId,
name,
ownerId,
}: {
calendarEventId: TournamentTeam["calendarEventId"];
name: TournamentTeam["name"];
ownerId: User["id"];
}) => {
const team = createTeamStm.get({
calendarEventId,
name,
inviteCode: nanoid(INVITE_CODE_LENGTH),
}) as TournamentTeam;

View File

@@ -1,32 +1,45 @@
import { sql } from "~/db/sql";
import type { CalendarEvent, User } from "~/db/types";
import type { CalendarEvent, CalendarEventDate, User } from "~/db/types";
// TODO: doesn't work if many start times
const stm = sql.prepare(/*sql*/ `
select
"CalendarEvent"."name",
"CalendarEvent"."description",
"CalendarEvent"."id",
"CalendarEvent"."bracketUrl",
"CalendarEvent"."authorId",
"User"."discordName",
"User"."discordDiscriminator",
"User"."discordId"
"CalendarEvent"."id",
"CalendarEvent"."bracketUrl",
"CalendarEvent"."authorId",
"CalendarEvent"."isBeforeStart",
"CalendarEvent"."toToolsMode",
"CalendarEventDate"."startTime",
"User"."discordName",
"User"."discordDiscriminator",
"User"."discordId"
from "CalendarEvent"
left join "User" on "CalendarEvent"."authorId" = "User"."id"
left join "CalendarEventDate" on "CalendarEvent"."id" = "CalendarEventDate"."eventId"
where
(
"CalendarEvent"."id" = @identifier
or "CalendarEvent"."customUrl" = @identifier
)
and "CalendarEvent"."toToolsEnabled" = 1
group by "CalendarEvent"."id"
`);
type FindByIdentifierRow =
| (Pick<
CalendarEvent,
"bracketUrl" | "id" | "name" | "description" | "authorId"
| "bracketUrl"
| "id"
| "name"
| "description"
| "authorId"
| "isBeforeStart"
| "toToolsMode"
> &
Pick<User, "discordId" | "discordName" | "discordDiscriminator">)
Pick<User, "discordId" | "discordName" | "discordDiscriminator"> &
Pick<CalendarEventDate, "startTime">)
| null;
export function findByIdentifier(identifier: string | number) {

View File

@@ -5,7 +5,6 @@ const stm = sql.prepare(/*sql*/ `
select
"TournamentTeam"."id",
"TournamentTeam"."name",
"TournamentTeam"."friendCode",
"TournamentTeam"."checkedInAt",
"TournamentTeam"."inviteCode"
from
@@ -20,7 +19,7 @@ const stm = sql.prepare(/*sql*/ `
type FindOwnTeam = Pick<
TournamentTeam,
"id" | "name" | "friendCode" | "checkedInAt" | "inviteCode"
"id" | "name" | "checkedInAt" | "inviteCode"
> | null;
export function findOwnTeam({

View File

@@ -0,0 +1,20 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/* sql */ `
update
"CalendarEvent"
set
"isBeforeStart" = @isBeforeStart
where
"id" = @id;
`);
export function updateIsBeforeStart({
id,
isBeforeStart,
}: {
id: number;
isBeforeStart: number;
}) {
return stm.run({ id, isBeforeStart });
}

View File

@@ -5,8 +5,7 @@ const stm = sql.prepare(/*sql*/ `
update
"TournamentTeam"
set
"name" = @name,
"friendCode" = @friendCode
"name" = @name
where
"id" = @id
`);
@@ -14,15 +13,12 @@ const stm = sql.prepare(/*sql*/ `
export function updateTeamInfo({
id,
name,
friendCode,
}: {
id: TournamentTeam["id"];
name: TournamentTeam["name"];
friendCode: TournamentTeam["friendCode"];
}) {
stm.run({
id,
name,
friendCode,
});
}

View File

@@ -0,0 +1,139 @@
import type { LoaderArgs, ActionFunction } from "@remix-run/node";
import { useLoaderData, useSubmit } from "@remix-run/react";
import * as React from "react";
import invariant from "tiny-invariant";
import { z } from "zod";
import { Button } from "~/components/Button";
import { FormMessage } from "~/components/FormMessage";
import { Toggle } from "~/components/Toggle";
import { useTranslation } from "~/hooks/useTranslation";
import { canAdminCalendarTOTools } from "~/permissions";
import { notFoundIfFalsy, parseRequestFormData, validate } from "~/utils/remix";
import { discordFullName } from "~/utils/strings";
import { checkboxValueToBoolean } from "~/utils/zod";
import { findByIdentifier } from "../queries/findByIdentifier.server";
import { findTeamsByEventId } from "../queries/findTeamsByEventId.server";
import { updateIsBeforeStart } from "../queries/updateIsBeforeStart.server";
import { requireUserId } from "~/modules/auth/user.server";
import { idFromParams } from "../tournament-utils";
const tournamentToolsActionSchema = z.object({
started: z.preprocess(checkboxValueToBoolean, z.boolean()),
});
export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUserId(request);
const data = await parseRequestFormData({
request,
schema: tournamentToolsActionSchema,
});
const eventId = idFromParams(params);
const event = notFoundIfFalsy(findByIdentifier(eventId));
validate(canAdminCalendarTOTools({ user, event }));
updateIsBeforeStart({
id: event.id,
isBeforeStart: Number(!data.started),
});
return null;
};
export const loader = async ({ params, request }: LoaderArgs) => {
const user = await requireUserId(request);
const eventId = idFromParams(params);
const event = notFoundIfFalsy(findByIdentifier(eventId));
notFoundIfFalsy(canAdminCalendarTOTools({ user, event }));
// could also get these from the layout page
// but getting them again for the most fresh data
return {
event,
teams: findTeamsByEventId(event.id),
};
};
export default function TournamentToolsAdminPage() {
const { t } = useTranslation(["tournament"]);
const submit = useSubmit();
const data = useLoaderData<typeof loader>();
const [eventStarted, setEventStarted] = React.useState(
Boolean(!data.event.isBeforeStart)
);
function handleToggle(toggled: boolean) {
setEventStarted(toggled);
const data = new FormData();
data.append("started", toggled ? "on" : "off");
submit(data, { method: "post" });
}
function discordListContent() {
return data.teams
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map((team) => {
const owner = team.members.find((user) => user.isOwner);
invariant(owner);
return `${team.name} - ${discordFullName(owner)} - <@${
owner.discordId
}>`;
})
.join("\n");
}
return (
<div className="stack md half-width">
<div>
<label>{t("tournament:admin.eventStarted")}</label>
<Toggle
checked={eventStarted}
setChecked={handleToggle}
name="started"
/>
<FormMessage type="info">
{t("tournament:admin.eventStarted.explanation")}
</FormMessage>
</div>
<div>
<label>{t("tournament:admin.download")}</label>
<div className="stack horizontal sm">
<Button
size="tiny"
onClick={() =>
handleDownload({
filename: "discord.txt",
content: discordListContent(),
})
}
>
{t("tournament:admin.download.discord")}
</Button>
</div>
</div>
</div>
);
}
function handleDownload({
content,
filename,
}: {
content: string;
filename: string;
}) {
const element = document.createElement("a");
const file = new Blob([content], {
type: "text/plain",
});
element.href = URL.createObjectURL(file);
element.download = filename;
document.body.appendChild(element);
element.click();
}

View File

@@ -1,5 +1,16 @@
import { redirect } from "@remix-run/node";
import { type LoaderArgs, redirect } from "@remix-run/node";
import { idFromParams } from "../tournament-utils";
import { notFoundIfFalsy } from "~/utils/remix";
import { findByIdentifier } from "../queries/findByIdentifier.server";
import { toToolsMapsPage, toToolsRegisterPage } from "~/utils/urls";
export const loader = () => {
return redirect("register");
export const loader = ({ params }: LoaderArgs) => {
const eventId = idFromParams(params);
const event = notFoundIfFalsy(findByIdentifier(eventId));
if (event.isBeforeStart) {
throw redirect(toToolsRegisterPage(event.id));
}
throw redirect(toToolsMapsPage(event.id));
};

View File

@@ -100,15 +100,7 @@ export default function JoinTeamPage() {
case "VALID": {
invariant(teamToJoin);
const teamName = teamToJoin.name;
if (!teamName) {
const owner = teamToJoin.members.find((member) => member.isOwner);
invariant(owner);
return `Join ${owner.discordName}'s team for ${parentRouteData.event.name}?`;
}
return `Join ${teamName} for ${parentRouteData.event.name}?`;
return `Join ${teamToJoin.name} for ${parentRouteData.event.name}?`;
}
default: {
assertUnreachable(validationStatus);
@@ -144,7 +136,7 @@ function validateCanJoin({
if (!teamToJoin) {
return "NO_TEAM_MATCHING_CODE";
}
if (teamToJoin.members.length >= TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL) {
if (teamToJoin.members.length >= TOURNAMENT.TEAM_MAX_MEMBERS) {
return "TEAM_FULL";
}
if (teamToJoin.members.some((member) => member.userId === userId)) {

View File

@@ -0,0 +1,364 @@
import type { LinksFunction } from "@remix-run/node";
import { useActionData, useOutletContext } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import { Alert } from "~/components/Alert";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import { useTranslation } from "~/hooks/useTranslation";
import { MapPool } from "~/modules/map-pool-serializer";
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator";
import {
createTournamentMapList,
type BracketType,
type TournamentMaplistInput,
type TournamentMaplistSource,
} from "~/modules/tournament-map-list-generator";
import mapsStyles from "~/styles/maps.css";
import { type SendouRouteHandle } from "~/utils/remix";
import { TOURNAMENT } from "../tournament-constants";
import type { TournamentToolsLoaderData } from "./to.$id";
import type { MapPoolMap } from "~/db/types";
import { modesIncluded, resolveOwnedTeam } from "../tournament-utils";
import { useUser } from "~/modules/auth";
import { Redirect } from "~/components/Redirect";
import { toToolsPage } from "~/utils/urls";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: mapsStyles }];
};
export const handle: SendouRouteHandle = {
i18n: ["tournament"],
};
type TeamInState = {
id: number;
mapPool?: Pick<MapPoolMap, "mode" | "stageId">[];
};
export default function TournamentToolsMapsPage() {
const user = useUser();
const { t } = useTranslation(["tournament"]);
const actionData = useActionData<{ failed?: boolean }>();
const data = useOutletContext<TournamentToolsLoaderData>();
const [bestOf, setBestOf] = useSearchParamState<
(typeof TOURNAMENT)["AVAILABLE_BEST_OF"][number]
>({
name: "bo",
defaultValue: 3,
revive: reviveBestOf,
});
const [teamOneId, setTeamOneId] = useSearchParamState({
name: "team-one",
defaultValue:
resolveOwnedTeam({ teams: data.teams, userId: user?.id })?.id ??
data.teams[0]!.id,
revive: reviveTeam(data.teams.map((t) => t.id)),
});
const [teamTwoId, setTeamTwoId] = useSearchParamState({
name: "team-two",
defaultValue: data.teams[1]!.id,
revive: reviveTeam(
data.teams.map((t) => t.id),
teamOneId
),
});
const [roundNumber, setRoundNumber] = useSearchParamState({
name: "round",
defaultValue: 1,
revive: reviveRound,
});
const [bracketType, setBracketType] = useSearchParamState<BracketType>({
name: "bracket",
defaultValue: "DE_WINNERS",
revive: reviveBracketType,
});
const teamOne = data.teams.find((t) => t.id === teamOneId) ?? {
id: -1,
mapPool: [],
};
const teamTwo = data.teams.find((t) => t.id === teamTwoId) ?? {
id: -1,
mapPool: [],
};
if (!data.mapListGeneratorAvailable) {
return <Redirect to={toToolsPage(data.event.id)} />;
}
return (
<div className="stack md">
{actionData?.failed && (
<Alert variation="ERROR" tiny>
{t("tournament:generator.error")}
</Alert>
)}
<RoundSelect
roundNumber={roundNumber}
bracketType={bracketType}
handleChange={(roundNumber, bracketType) => {
setRoundNumber(roundNumber);
setBracketType(bracketType);
}}
/>
<div className="tournament__teams-container">
<TeamsSelect
number={1}
team={teamOne}
otherTeam={teamTwo}
setTeam={setTeamOneId}
/>
<TeamsSelect
number={2}
team={teamTwo}
otherTeam={teamOne}
setTeam={setTeamTwoId}
/>
</div>
<BestOfRadios bestOf={bestOf} setBestOf={setBestOf} />
<MapList
teams={[
{ ...teamOne, maps: new MapPool(teamOne.mapPool ?? []) },
{ ...teamTwo, maps: new MapPool(teamTwo.mapPool ?? []) },
]}
bestOf={bestOf}
bracketType={bracketType}
roundNumber={roundNumber}
modesIncluded={modesIncluded(data.event)}
/>
</div>
);
}
const BRACKET_TYPES: Array<BracketType> = ["DE_WINNERS", "DE_LOSERS"];
const AMOUNT_OF_ROUNDS = 12;
function reviveBestOf(value: string) {
const parsed = Number(value);
return TOURNAMENT.AVAILABLE_BEST_OF.find((bo) => bo === parsed);
}
function reviveBracketType(value: string) {
return BRACKET_TYPES.find((bracketType) => bracketType === value);
}
function reviveRound(value: string) {
const parsed = Number(value);
return new Array(AMOUNT_OF_ROUNDS)
.fill(null)
.map((_, i) => i + 1)
.find((val) => val === parsed);
}
function reviveTeam(teamIds: number[], excludedTeamId?: number) {
return function (value: string) {
const parsed = Number(value);
return teamIds
.filter((id) => id !== excludedTeamId)
.find((id) => id === parsed);
};
}
function RoundSelect({
roundNumber,
bracketType,
handleChange,
}: {
roundNumber: TournamentMaplistInput["roundNumber"];
bracketType: TournamentMaplistInput["bracketType"];
handleChange: (roundNumber: number, bracketType: BracketType) => void;
}) {
const { t } = useTranslation(["tournament"]);
return (
<div className="tournament__round-container tournament__select-container">
<label htmlFor="round">{t("tournament:round.label")}</label>
<select
id="round"
value={`${bracketType}-${roundNumber}`}
onChange={(e) => {
const [bracketType, roundNumber] = e.target.value.split("-") as [
BracketType,
string
];
handleChange(Number(roundNumber), bracketType);
}}
>
{BRACKET_TYPES.flatMap((type) =>
new Array(AMOUNT_OF_ROUNDS).fill(null).map((_, i) => {
return (
<option key={`${type}-${i}`} value={`${type}-${i + 1}`}>
{t(`tournament:bracket.type.${type}`)} {i + 1}
</option>
);
})
)}
</select>
</div>
);
}
function TeamsSelect({
number,
team,
otherTeam,
setTeam,
}: {
number: number;
team: { id: number };
otherTeam: TeamInState;
setTeam: (newTeamId: number) => void;
}) {
const { t } = useTranslation(["tournament"]);
const data = useOutletContext<TournamentToolsLoaderData>();
return (
<div className="tournament__select-container">
<label htmlFor="round">
{t("tournament:team.label")} {number}
</label>
<select
id="round"
className="tournament__team-select"
value={team.id}
onChange={(e) => {
setTeam(Number(e.target.value));
}}
>
<option value={-1}>({t("tournament:team.unlisted")})</option>
{data.teams
.filter((t) => t.id !== otherTeam.id)
.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
</select>
</div>
);
}
function BestOfRadios({
bestOf,
setBestOf,
}: {
bestOf: (typeof TOURNAMENT)["AVAILABLE_BEST_OF"][number];
setBestOf: (bestOf: (typeof TOURNAMENT)["AVAILABLE_BEST_OF"][number]) => void;
}) {
const { t } = useTranslation(["tournament"]);
return (
<div className="tournament__bo-radios-container">
{TOURNAMENT.AVAILABLE_BEST_OF.map((bestOfOption) => (
<div key={bestOfOption}>
<label htmlFor={String(bestOfOption)}>
{t("tournament:bestOf.label.short")}
{bestOfOption}
</label>
<input
id={String(bestOfOption)}
name="bestOf"
type="radio"
checked={bestOfOption === bestOf}
onChange={() => setBestOf(bestOfOption)}
/>
</div>
))}
</div>
);
}
function MapList(props: Omit<TournamentMaplistInput, "tiebreakerMaps">) {
const { t } = useTranslation(["game-misc"]);
const data = useOutletContext<TournamentToolsLoaderData>();
let mapList: Array<TournamentMapListMap>;
try {
mapList = createTournamentMapList({
...props,
tiebreakerMaps: new MapPool(data.tieBreakerMapPool),
});
} catch (e) {
console.error(
"Failed to create map list. Falling back to default maps.",
e
);
mapList = createTournamentMapList({
...props,
teams: [
{
id: -1,
maps: new MapPool([]),
},
{
id: -2,
maps: new MapPool([]),
},
],
tiebreakerMaps: new MapPool(data.tieBreakerMapPool),
});
}
return (
<div className="tournament__map-list">
{mapList.map(({ stageId, mode, source }, i) => {
return (
<React.Fragment key={`${stageId}-${mode}`}>
<PickInfoText
source={source}
teamOneId={props.teams[0].id}
teamTwoId={props.teams[1].id}
/>
<div key={stageId} className="tournament__stage-listed">
{i + 1}) {mode} {t(`game-misc:STAGE_${stageId}`)}
</div>
</React.Fragment>
);
})}
</div>
);
}
function PickInfoText({
source,
teamOneId,
teamTwoId,
}: {
source: TournamentMaplistSource;
teamOneId: number;
teamTwoId: number;
}) {
const { t } = useTranslation(["tournament"]);
const text = () => {
if (source === teamOneId)
return t("tournament:pickInfo.team", { number: 1 });
if (source === teamTwoId)
return t("tournament:pickInfo.team", { number: 2 });
if (source === "TIEBREAKER") return t("tournament:pickInfo.tiebreaker");
if (source === "BOTH") return t("tournament:pickInfo.both");
if (source === "DEFAULT") return t("tournament:pickInfo.default");
console.error(`Unknown source: ${String(source)}`);
return "";
};
const otherClassName = () => {
if (source === teamOneId) return "team-1";
if (source === teamTwoId) return "team-2";
return typeof source === "string" ? source.toLocaleLowerCase() : source;
};
return (
<div className={clsx("tournament__pick-info", otherClassName())}>
{text()}
</div>
);
}

View File

@@ -1,17 +1,16 @@
import type {
ActionFunction,
LoaderArgs,
SerializeFrom,
import {
type ActionFunction,
type LoaderArgs,
type SerializeFrom,
redirect,
} from "@remix-run/node";
import { useFetcher, useLoaderData, useOutletContext } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import { useCopyToClipboard } from "react-use";
import invariant from "tiny-invariant";
import { Alert } from "~/components/Alert";
import { Avatar } from "~/components/Avatar";
import { Button } from "~/components/Button";
import { FormMessage } from "~/components/FormMessage";
import { Image } from "~/components/Image";
import { Input } from "~/components/Input";
import { Label } from "~/components/Label";
@@ -19,11 +18,16 @@ import { SubmitButton } from "~/components/SubmitButton";
import { useTranslation } from "~/hooks/useTranslation";
import { useUser } from "~/modules/auth";
import { getUserId, requireUserId } from "~/modules/auth/user.server";
import type { RankedModeShort, StageId } from "~/modules/in-game-lists";
import type {
ModeShort,
RankedModeShort,
StageId,
} from "~/modules/in-game-lists";
import { stageIds } from "~/modules/in-game-lists";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import { MapPool } from "~/modules/map-pool-serializer";
import {
notFoundIfFalsy,
parseRequestFormData,
validate,
type SendouRouteHandle,
@@ -34,20 +38,34 @@ import { assertUnreachable } from "~/utils/types";
import {
CALENDAR_PAGE,
LOG_IN_URL,
SENDOU_INK_BASE_URL,
modeImageUrl,
navIconUrl,
toToolsJoinPage,
toToolsMapsPage,
} from "~/utils/urls";
import { createTeam } from "../queries/createTeam.server";
import deleteTeamMember from "../queries/deleteTeamMember.server";
import { findByIdentifier } from "../queries/findByIdentifier.server";
import { findOwnTeam } from "../queries/findOwnTeam.server";
import { findTeamsByEventId } from "../queries/findTeamsByEventId.server";
import { updateTeamInfo } from "../queries/updateTeamInfo.server";
import { upsertCounterpickMaps } from "../queries/upsertCounterpickMaps.server";
import { FRIEND_CODE_REGEX_PATTERN, TOURNAMENT } from "../tournament-constants";
import { TOURNAMENT } from "../tournament-constants";
import { useSelectCounterpickMapPoolState } from "../tournament-hooks";
import { registerSchema } from "../tournament-schemas.server";
import { idFromParams, resolveOwnedTeam } from "../tournament-utils";
import {
isOneModeTournamentOf,
HACKY_resolvePicture,
idFromParams,
resolveOwnedTeam,
HACKY_resolveCheckInTime,
} from "../tournament-utils";
import type { TournamentToolsLoaderData } from "./to.$id";
import { createTeam } from "../queries/createTeam.server";
import { ClockIcon } from "~/components/icons/Clock";
import { databaseTimestampToDate } from "~/utils/dates";
import { UserIcon } from "~/components/icons/User";
import { useIsMounted } from "~/hooks/useIsMounted";
export const handle: SendouRouteHandle = {
breadcrumb: () => ({
@@ -62,6 +80,9 @@ export const action: ActionFunction = async ({ request, params }) => {
const data = await parseRequestFormData({ request, schema: registerSchema });
const eventId = idFromParams(params);
const event = notFoundIfFalsy(findByIdentifier(eventId));
invariant(event.isBeforeStart);
const teams = findTeamsByEventId(eventId);
const ownTeam = teams.find((team) =>
@@ -69,27 +90,19 @@ export const action: ActionFunction = async ({ request, params }) => {
);
switch (data._action) {
case "CREATE_TEAM": {
const userIsInTeam = teams.some((team) =>
team.members.some((member) => member.userId === user.id)
);
validate(!userIsInTeam);
// TODO tournament: make sure tournament has not started
createTeam({ calendarEventId: idFromParams(params), ownerId: user.id });
break;
}
case "UPDATE_TEAM_INFO": {
validate(ownTeam);
// TODO tournament: make sure not changing name AND tournament is happening
updateTeamInfo({
friendCode: data.friendCode,
name: data.teamName,
id: ownTeam.id,
});
case "UPSERT_TEAM": {
if (ownTeam) {
updateTeamInfo({
name: data.teamName,
id: ownTeam.id,
});
} else {
createTeam({
name: data.teamName,
calendarEventId: eventId,
ownerId: user.id,
});
}
break;
}
case "DELETE_TEAM_MEMBER": {
@@ -97,17 +110,17 @@ export const action: ActionFunction = async ({ request, params }) => {
validate(ownTeam.members.some((member) => member.userId === data.userId));
validate(data.userId !== user.id);
// TODO tournament: make sure tournament not happening
deleteTeamMember({ tournamentTeamId: ownTeam.id, userId: data.userId });
break;
}
case "UPDATE_MAP_POOL": {
const mapPool = new MapPool(data.mapPool);
validate(ownTeam);
validate(validateCounterPickMapPool(mapPool) === "VALID");
validate(
validateCounterPickMapPool(mapPool, isOneModeTournamentOf(event)) ===
"VALID"
);
// TODO tournament: make sure tournament not happening
upsertCounterpickMaps({
tournamentTeamId: ownTeam.id,
mapPool: new MapPool(data.mapPool),
@@ -123,8 +136,14 @@ export const action: ActionFunction = async ({ request, params }) => {
};
export const loader = async ({ request, params }: LoaderArgs) => {
const user = await getUserId(request);
const eventId = idFromParams(params);
const event = notFoundIfFalsy(findByIdentifier(eventId));
if (!event.isBeforeStart) {
throw redirect(toToolsMapsPage(event.id));
}
const user = await getUserId(request);
if (!user) return null;
const ownTeam = findOwnTeam({
@@ -139,6 +158,8 @@ export const loader = async ({ request, params }: LoaderArgs) => {
};
export default function TournamentRegisterPage() {
const isMounted = useIsMounted();
const { i18n } = useTranslation();
const user = useUser();
const data = useLoaderData<typeof loader>();
const parentRouteData = useOutletContext<TournamentToolsLoaderData>();
@@ -150,9 +171,8 @@ export default function TournamentRegisterPage() {
return (
<div className="stack lg">
<div className="tournament__logo-container">
{/* TODO tournament: dynamic image */}
<img
src="https://abload.de/img/screenshot2022-12-15ap0ca1.png"
src={HACKY_resolvePicture(parentRouteData.event)}
alt=""
className="tournament__logo"
width={124}
@@ -161,57 +181,120 @@ export default function TournamentRegisterPage() {
<div>
<div className="tournament__title">{parentRouteData.event.name}</div>
<div className="tournament__by">
by {discordFullName(parentRouteData.event.author)}
<div className="stack horizontal xs items-center">
<UserIcon className="tournament__info__icon" />{" "}
{discordFullName(parentRouteData.event.author)}
</div>
<div className="stack horizontal xs items-center">
<ClockIcon className="tournament__info__icon" />{" "}
{isMounted
? databaseTimestampToDate(
parentRouteData.event.startTime
).toLocaleString(i18n.language, {
timeZoneName: "short",
minute: "numeric",
hour: "numeric",
day: "numeric",
month: "numeric",
})
: null}
</div>
</div>
</div>
</div>
<div>{parentRouteData.event.description}</div>
{teamRegularMemberOf ? (
<Alert>You are in a team for this event</Alert>
) : !data?.ownTeam ? (
<Register />
) : (
<div>
<EditTeam ownTeam={data.ownTeam} />
</div>
<RegistrationForms ownTeam={data?.ownTeam} />
)}
</div>
);
}
function Register() {
const user = useUser();
const fetcher = useFetcher();
if (!user) {
return (
<form className="stack items-center" action={LOG_IN_URL} method="post">
<Button size="big" type="submit">
Log in to register
</Button>
</form>
);
}
function PleaseLogIn() {
return (
<fetcher.Form className="stack items-center" method="post">
<SubmitButton size="big" state={fetcher.state} _action="CREATE_TEAM">
Register now
</SubmitButton>
</fetcher.Form>
<form className="stack items-center" action={LOG_IN_URL} method="post">
<Button size="big" type="submit">
Log in to register
</Button>
</form>
);
}
function EditTeam({
function RegistrationForms({
ownTeam,
}: {
ownTeam: NonNullable<SerializeFrom<typeof loader>>["ownTeam"];
ownTeam?: NonNullable<SerializeFrom<typeof loader>>["ownTeam"];
}) {
const user = useUser();
if (!user) return <PleaseLogIn />;
return (
<div className="stack lg">
<FillRoster ownTeam={ownTeam} />
<RegisterToBracket />
<TeamInfo ownTeam={ownTeam} />
<CounterPickMapPoolPicker />
{ownTeam ? (
<>
<FillRoster ownTeam={ownTeam} />
<CounterPickMapPoolPicker />
<RememberToCheckin />
</>
) : null}
</div>
);
}
function RegisterToBracket() {
const parentRouteData = useOutletContext<TournamentToolsLoaderData>();
return (
<div>
<h3 className="tournament__section-header">1. Register</h3>
<section className="tournament__section text-center text-sm font-semi-bold">
Register on{" "}
<a
href={parentRouteData.event.bracketUrl}
target="_blank"
rel="noopener noreferrer"
>
{parentRouteData.event.bracketUrl}
</a>
</section>
</div>
);
}
function TeamInfo({
ownTeam,
}: {
ownTeam?: NonNullable<SerializeFrom<typeof loader>>["ownTeam"];
}) {
const fetcher = useFetcher();
return (
<div>
<h3 className="tournament__section-header">2. Team info</h3>
<section className="tournament__section">
<fetcher.Form method="post" className="stack md items-center">
<div className="tournament__section__input-container">
<Label htmlFor="teamName">Team name</Label>
<Input
name="teamName"
id="teamName"
required
maxLength={TOURNAMENT.TEAM_NAME_MAX_LENGTH}
defaultValue={ownTeam?.name ?? undefined}
/>
</div>
<SubmitButton _action="UPSERT_TEAM" state={fetcher.state}>
Save
</SubmitButton>
</fetcher.Form>
</section>
<div className="tournament__section__warning">
Use the same name as on the bracket
</div>
</div>
);
}
@@ -226,7 +309,10 @@ function FillRoster({
const [, copyToClipboard] = useCopyToClipboard();
const { t } = useTranslation(["common"]);
const inviteLink = `https://sendou.ink/to/201/join?code=${ownTeam.inviteCode}`;
const inviteLink = `${SENDOU_INK_BASE_URL}${toToolsJoinPage({
eventId: parentRouteData.event.id,
inviteCode: ownTeam.inviteCode,
})}`;
const { members: ownTeamMembers } =
resolveOwnedTeam({
@@ -240,12 +326,11 @@ function FillRoster({
0
);
// TODO tournament: + tournament has not started
const showDeleteMemberSection = ownTeamMembers.length > 1;
return (
<div>
<h3 className="tournament__section-header">1. Fill roster</h3>
<h3 className="tournament__section-header">3. Fill roster</h3>
<section className="tournament__section stack lg items-center">
<div className="stack md items-center">
<div className="text-center text-sm">
@@ -281,16 +366,9 @@ function FillRoster({
<DeleteMember members={ownTeamMembers} />
) : null}
</section>
<div
className={clsx("tournament__section__warning", {
"text-warning":
ownTeamMembers.length < TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL,
"text-success":
ownTeamMembers.length >= TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL,
})}
>
{TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL}-{TOURNAMENT.TEAM_MAX_MEMBERS}{" "}
members needed to play
<div className="tournament__section__warning">
You can still play without submitting roster, but you might be seeded
lower in the bracket.
</div>
</div>
);
@@ -342,50 +420,6 @@ function DeleteMember({
);
}
function TeamInfo({
ownTeam,
}: {
ownTeam: NonNullable<SerializeFrom<typeof loader>>["ownTeam"];
}) {
const fetcher = useFetcher();
return (
<div>
<h3 className="tournament__section-header">2. Team info</h3>
<section className="tournament__section">
<fetcher.Form method="post" className="stack md items-center">
<div className="tournament__section__input-container">
<Label htmlFor="teamName">Team name</Label>
<Input
name="teamName"
id="teamName"
required
maxLength={TOURNAMENT.TEAM_NAME_MAX_LENGTH}
defaultValue={ownTeam.name ?? undefined}
/>
</div>
<div className="tournament__section__input-container">
<Label htmlFor="friendCode">Friend code</Label>
<Input
name="friendCode"
id="friendCode"
required
placeholder="1209-3932-9498"
pattern={String(FRIEND_CODE_REGEX_PATTERN)}
defaultValue={ownTeam.friendCode ?? undefined}
/>
<FormMessage type="info">
The friend code your opponents should add during tournament
</FormMessage>
</div>
<SubmitButton _action="UPDATE_TEAM_INFO" state={fetcher.state}>
Save
</SubmitButton>
</fetcher.Form>
</section>
</div>
);
}
function CounterPickMapPoolPicker() {
const { t } = useTranslation(["common", "game-misc"]);
const parentRouteData = useOutletContext<TournamentToolsLoaderData>();
@@ -409,7 +443,7 @@ function CounterPickMapPoolPicker() {
return (
<div>
<h3 className="tournament__section-header">3. Pick map pool</h3>
<h3 className="tournament__section-header">4. Pick map pool</h3>
<section className="tournament__section">
<fetcher.Form
method="post"
@@ -420,58 +454,73 @@ function CounterPickMapPoolPicker() {
name="mapPool"
value={counterPickMapPool.serialized}
/>
{rankedModesShort.map((mode) => {
const tiebreakerStageId = parentRouteData.tieBreakerMapPool.find(
(stage) => stage.mode === mode
)?.stageId;
{rankedModesShort
.filter(
(mode) =>
!isOneModeTournamentOf(parentRouteData.event) ||
isOneModeTournamentOf(parentRouteData.event) === mode
)
.map((mode) => {
const tiebreakerStageId = parentRouteData.tieBreakerMapPool.find(
(stage) => stage.mode === mode
)?.stageId;
return (
<div key={mode} className="stack md">
<div className="stack sm">
<div className="stack horizontal sm items-center font-bold">
<Image
path={modeImageUrl(mode)}
width={32}
height={32}
alt=""
/>
{t(`game-misc:MODE_LONG_${mode}`)}
return (
<div key={mode} className="stack md">
<div className="stack sm">
<div className="stack horizontal sm items-center font-bold">
<Image
path={modeImageUrl(mode)}
width={32}
height={32}
alt=""
/>
{t(`game-misc:MODE_LONG_${mode}`)}
</div>
{typeof tiebreakerStageId === "number" ? (
<div className="text-xs text-lighter">
Tiebreaker: {t(`game-misc:STAGE_${tiebreakerStageId}`)}
</div>
) : null}
</div>
{typeof tiebreakerStageId === "number" ? (
<div className="text-xs text-lighter">
Tiebreaker: {t(`game-misc:STAGE_${tiebreakerStageId}`)}
</div>
) : null}
{new Array(
isOneModeTournamentOf(parentRouteData.event)
? TOURNAMENT.COUNTERPICK_ONE_MODE_TOURNAMENT_MAPS_PER_MODE
: TOURNAMENT.COUNTERPICK_MAPS_PER_MODE
)
.fill(null)
.map((_, i) => {
return (
<div
key={i}
className="tournament__section__map-select-row"
>
Pick {i + 1}{" "}
<select
value={counterpickMaps[mode][i] ?? undefined}
onChange={handleCounterpickMapPoolSelect(mode, i)}
>
<option value=""></option>
{stageIds
.filter((id) => id !== tiebreakerStageId)
.map((stageId) => {
return (
<option key={stageId} value={stageId}>
{t(`game-misc:STAGE_${stageId}`)}
</option>
);
})}
</select>
</div>
);
})}
</div>
{new Array(2).fill(null).map((_, i) => {
return (
<div
key={i}
className="tournament__section__map-select-row"
>
Pick {i + 1}{" "}
<select
value={counterpickMaps[mode][i] ?? undefined}
onChange={handleCounterpickMapPoolSelect(mode, i)}
>
<option value=""></option>
{stageIds
.filter((id) => id !== tiebreakerStageId)
.map((stageId) => {
return (
<option key={stageId} value={stageId}>
{t(`game-misc:STAGE_${stageId}`)}
</option>
);
})}
</select>
</div>
);
})}
</div>
);
})}
{validateCounterPickMapPool(counterPickMapPool) === "VALID" ? (
);
})}
{validateCounterPickMapPool(
counterPickMapPool,
isOneModeTournamentOf(parentRouteData.event)
) === "VALID" ? (
<SubmitButton
_action="UPDATE_MAP_POOL"
state={fetcher.state}
@@ -481,11 +530,18 @@ function CounterPickMapPoolPicker() {
</SubmitButton>
) : (
<MapPoolValidationStatusMessage
status={validateCounterPickMapPool(counterPickMapPool)}
status={validateCounterPickMapPool(
counterPickMapPool,
isOneModeTournamentOf(parentRouteData.event)
)}
/>
)}
</fetcher.Form>
</section>
<div className="tournament__section__warning">
Picking a map pool is optional, but if you don&apos;t then you will be
playing on your opponent&apos;s picks.
</div>
</div>
);
}
@@ -516,14 +572,19 @@ type CounterPickValidationStatus =
| "TOO_MUCH_STAGE_REPEAT";
function validateCounterPickMapPool(
mapPool: MapPool
mapPool: MapPool,
isOneModeOnlyTournamentFor: ModeShort | null
): CounterPickValidationStatus {
const stageCounts = new Map<StageId, number>();
for (const stageId of mapPool.stages) {
if (!stageCounts.has(stageId)) {
stageCounts.set(stageId, 0);
}
if (stageCounts.get(stageId)! === TOURNAMENT.COUNTERPICK_MAX_STAGE_REPEAT) {
if (
stageCounts.get(stageId)! >= TOURNAMENT.COUNTERPICK_MAX_STAGE_REPEAT ||
(isOneModeOnlyTournamentFor && stageCounts.get(stageId)! >= 1)
) {
return "TOO_MUCH_STAGE_REPEAT";
}
@@ -531,13 +592,54 @@ function validateCounterPickMapPool(
}
if (
mapPool.parsed.SZ.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE ||
mapPool.parsed.TC.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE ||
mapPool.parsed.RM.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE ||
mapPool.parsed.CB.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE
!isOneModeOnlyTournamentFor &&
(mapPool.parsed.SZ.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE ||
mapPool.parsed.TC.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE ||
mapPool.parsed.RM.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE ||
mapPool.parsed.CB.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE)
) {
return "PICKING";
}
if (
isOneModeOnlyTournamentFor &&
mapPool.parsed[isOneModeOnlyTournamentFor].length !==
TOURNAMENT.COUNTERPICK_ONE_MODE_TOURNAMENT_MAPS_PER_MODE
) {
return "PICKING";
}
return "VALID";
}
function RememberToCheckin() {
const { i18n } = useTranslation();
const isMounted = useIsMounted();
const parentRouteData = useOutletContext<TournamentToolsLoaderData>();
const checkInStartsString = isMounted
? HACKY_resolveCheckInTime(parentRouteData.event).toLocaleTimeString(
i18n.language,
{
minute: "numeric",
hour: "numeric",
}
)
: "";
return (
<div>
<h3 className="tournament__section-header">5. Check-in</h3>
<section className="tournament__section text-center text-sm font-semi-bold">
Check in starts at {checkInStartsString} here:{" "}
<a
href={parentRouteData.event.bracketUrl}
target="_blank"
rel="noopener noreferrer"
>
{parentRouteData.event.bracketUrl}
</a>
</section>
</div>
);
}

View File

@@ -22,7 +22,7 @@ export default function TournamentToolsTeamsPage() {
const hasMapPool = () => {
// before start empty array is returned if team has map list
// after start empty array means team has no map list
if (data.event.isBeforeStart) {
if (!data.mapListGeneratorAvailable) {
return Boolean(team.mapPool);
}

View File

@@ -50,15 +50,20 @@ export const loader = async ({ params, request }: LoaderArgs) => {
const eventId = idFromParams(params);
const event = notFoundIfFalsy(findByIdentifier(eventId));
const mapListGeneratorAvailable =
canAdminCalendarTOTools({ user, event }) || !event.isBeforeStart;
return {
// TODO tournament: remove isBeforeStart
event: { ...event, isBeforeStart: true },
event,
tieBreakerMapPool:
db.calendarEvents.findTieBreakerMapPoolByEventId(eventId),
teams: censorMapPools(findTeamsByEventId(eventId)),
mapListGeneratorAvailable,
};
function censorMapPools(teams: FindTeamsByEventId): FindTeamsByEventId {
if (mapListGeneratorAvailable) return teams;
return teams.map((team) =>
team.members.some(
(member) => member.userId === user?.id && member.isOwner
@@ -85,7 +90,12 @@ export default function TournamentToolsLayout() {
return (
<Main>
<SubNav>
<SubNavLink to="register">Register</SubNavLink>
{data.event.isBeforeStart ? (
<SubNavLink to="register">{t("tournament:tabs.register")}</SubNavLink>
) : null}
{data.mapListGeneratorAvailable ? (
<SubNavLink to="maps">{t("tournament:tabs.maps")}</SubNavLink>
) : null}
<SubNavLink to="teams">
{t("tournament:tabs.teams", { count: data.teams.length })}
</SubNavLink>

View File

@@ -2,9 +2,8 @@ export const TOURNAMENT = {
TEAM_NAME_MAX_LENGTH: 64,
COUNTERPICK_MAPS_PER_MODE: 2,
COUNTERPICK_MAX_STAGE_REPEAT: 2,
COUNTERPICK_ONE_MODE_TOURNAMENT_MAPS_PER_MODE: 6,
TEAM_MIN_MEMBERS_FOR_FULL: 4,
TEAM_MAX_MEMBERS: 6,
AVAILABLE_BEST_OF: [3, 5, 7] as const,
} as const;
export const FRIEND_CODE_REGEX_PATTERN = "^\\d{4}-\\d{4}-\\d{4}$";

View File

@@ -1,18 +1,15 @@
import { useOutletContext } from "@remix-run/react";
import * as React from "react";
import { useUser } from "~/modules/auth";
import type { RankedModeShort, StageId } from "~/modules/in-game-lists";
import type { TournamentToolsLoaderData } from "./routes/to.$id";
import { resolveOwnedTeam } from "./tournament-utils";
import * as React from "react";
import { TOURNAMENT } from "./tournament-constants";
import { mapPickCountPerMode, resolveOwnedTeam } from "./tournament-utils";
export function useSelectCounterpickMapPoolState() {
const user = useUser();
const parentRouteData = useOutletContext<TournamentToolsLoaderData>();
const resolveInitialMapPool = (
mode: RankedModeShort
): [StageId | null, StageId | null] => {
const resolveInitialMapPool = (mode: RankedModeShort) => {
const ownMapPool =
resolveOwnedTeam({
teams: parentRouteData.teams,
@@ -23,15 +20,15 @@ export function useSelectCounterpickMapPoolState() {
.filter((pair) => pair.mode === mode)
.map((pair) => pair.stageId);
if (filteredStages.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE) {
return [null, null];
if (filteredStages.length !== mapPickCountPerMode(parentRouteData.event)) {
return new Array(mapPickCountPerMode(parentRouteData.event)).fill(null);
}
return filteredStages as [StageId, StageId];
};
const [counterpickMaps, setCounterpickMaps] = React.useState<
Record<RankedModeShort, [StageId | null, StageId | null]>
Record<RankedModeShort, (StageId | null)[]>
>({
SZ: resolveInitialMapPool("SZ"),
TC: resolveInitialMapPool("TC"),
@@ -47,14 +44,15 @@ export function useSelectCounterpickMapPoolState() {
(e) => {
setCounterpickMaps({
...counterpickMaps,
[mode]: [counterpickMaps[mode][0], counterpickMaps[mode][1]].map(
(stageId, j) => {
[mode]: new Array(mapPickCountPerMode(parentRouteData.event))
.fill(null)
.map((_, i) => counterpickMaps[mode][i])
.map((stageId, j) => {
if (i === j) {
return e.target.value === "" ? null : Number(e.target.value);
}
return stageId;
}
),
}),
});
};

View File

@@ -1,13 +1,11 @@
import { z } from "zod";
import { id } from "~/utils/zod";
import { FRIEND_CODE_REGEX_PATTERN, TOURNAMENT } from "./tournament-constants";
import { TOURNAMENT } from "./tournament-constants";
export const registerSchema = z.union([
z.object({ _action: z.literal("CREATE_TEAM") }),
z.object({
_action: z.literal("UPDATE_TEAM_INFO"),
_action: z.literal("UPSERT_TEAM"),
teamName: z.string().min(1).max(TOURNAMENT.TEAM_NAME_MAX_LENGTH),
friendCode: z.string().regex(new RegExp(FRIEND_CODE_REGEX_PATTERN)),
}),
z.object({
_action: z.literal("UPDATE_MAP_POOL"),

View File

@@ -2,6 +2,11 @@ import type { Params } from "@remix-run/react";
import invariant from "tiny-invariant";
import type { User } from "~/db/types";
import type { FindTeamsByEventId } from "./queries/findTeamsByEventId.server";
import type { TournamentToolsLoaderData } from "./routes/to.$id";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import type { ModeShort } from "~/modules/in-game-lists";
import { TOURNAMENT } from "./tournament-constants";
import { databaseTimestampToDate } from "~/utils/dates";
export function resolveOwnedTeam({
teams,
@@ -23,3 +28,42 @@ export function idFromParams(params: Params<string>) {
return result;
}
export function modesIncluded(
event: TournamentToolsLoaderData["event"]
): ModeShort[] {
if (event.toToolsMode) return [event.toToolsMode];
return [...rankedModesShort];
}
export function isOneModeTournamentOf(
event: TournamentToolsLoaderData["event"]
) {
if (event.toToolsMode) return event.toToolsMode;
return null;
}
export function HACKY_resolvePicture(
event: TournamentToolsLoaderData["event"]
) {
if (event.name.includes("In The Zone"))
return "https://abload.de/img/screenshot2023-04-19a2bfv0.png";
return "https://abload.de/img/screenshot2022-12-15ap0ca1.png";
}
// hacky because db query not taking in account possibility of many start times
// AND always assumed check-in starts 1h before
export function HACKY_resolveCheckInTime(
event: TournamentToolsLoaderData["event"]
) {
return databaseTimestampToDate(event.startTime - 60 * 60);
}
export function mapPickCountPerMode(event: TournamentToolsLoaderData["event"]) {
return isOneModeTournamentOf(event)
? TOURNAMENT.COUNTERPICK_ONE_MODE_TOURNAMENT_MAPS_PER_MODE
: TOURNAMENT.COUNTERPICK_MAPS_PER_MODE;
}

View File

@@ -1,5 +1,3 @@
/** xxx: remove all unused **/
.tournament__action-section {
padding: var(--s-6);
border-radius: var(--rounded);
@@ -147,12 +145,9 @@
width: 1rem;
}
/** xxx: all new from here **/
.tournament__logo-container {
display: flex;
align-items: center;
margin: 0 auto;
gap: var(--s-4);
}
@@ -161,11 +156,15 @@
}
.tournament__title {
color: var(--theme);
font-size: var(--fonts-xl);
font-weight: var(--bold);
}
.tournament__info__icon {
width: 18px;
padding: var(--s-1) 0;
}
.tournament__by {
color: var(--text-lighter);
font-size: var(--fonts-sm);
@@ -195,6 +194,7 @@
font-size: var(--fonts-xs);
font-weight: var(--semi-bold);
text-align: center;
color: var(--text-lighter);
}
.tournament__section__map-select-row {

View File

@@ -87,6 +87,10 @@ export class MapPool {
);
}
overlaps(other: MapPool): boolean {
return this.stageModePairs.some((pair) => other.has(pair));
}
isEmpty(): boolean {
return Object.values(this.parsed).every((stages) => stages.length === 0);
}
@@ -103,6 +107,15 @@ export class MapPool {
return this.parsed;
}
[Symbol.iterator]() {
var index = -1;
var data = this.stageModePairs;
return {
next: () => ({ value: data[++index]!, done: !(index in data) }),
};
}
static EMPTY = new MapPool({
SZ: [],
TC: [],

View File

@@ -7,6 +7,9 @@ import { MapPool } from "../map-pool-serializer";
import type { TournamentMaplistInput } from "./types";
const TournamentMapListGenerator = suite("Tournament map list generator");
const TournamentMapListGeneratorOneMode = suite(
"Tournament map list generator (one mode)"
);
const team1Picks = new MapPool([
{ mode: "SZ", stageId: 4 },
@@ -50,6 +53,7 @@ const generateMaps = ({
},
],
tiebreakerMaps = tiebreakerPicks,
modesIncluded = [...rankedModesShort],
}: Partial<TournamentMaplistInput> = {}) => {
return createTournamentMapList({
bestOf,
@@ -57,6 +61,7 @@ const generateMaps = ({
roundNumber,
teams,
tiebreakerMaps,
modesIncluded,
});
};
@@ -348,4 +353,207 @@ TournamentMapListGenerator("No map picked by same team twice in row", () => {
}
});
const team1SZPicks = new MapPool([
{ mode: "SZ", stageId: 4 },
{ mode: "SZ", stageId: 5 },
{ mode: "SZ", stageId: 6 },
{ mode: "SZ", stageId: 7 },
{ mode: "SZ", stageId: 8 },
{ mode: "SZ", stageId: 9 },
]);
const team2SZPicks = new MapPool([
{ mode: "SZ", stageId: 1 },
{ mode: "SZ", stageId: 2 },
{ mode: "SZ", stageId: 3 },
{ mode: "SZ", stageId: 9 },
{ mode: "SZ", stageId: 10 },
{ mode: "SZ", stageId: 11 },
]);
const team2SZPicksNoOverlap = new MapPool([
{ mode: "SZ", stageId: 1 },
{ mode: "SZ", stageId: 2 },
{ mode: "SZ", stageId: 3 },
{ mode: "SZ", stageId: 14 },
{ mode: "SZ", stageId: 10 },
{ mode: "SZ", stageId: 11 },
]);
TournamentMapListGeneratorOneMode(
"Creates map list for one mode inferring from the team picks",
() => {
const mapList = generateMaps({
teams: [
{
id: 1,
maps: team1SZPicks,
},
{
id: 2,
maps: team2SZPicks,
},
],
modesIncluded: ["SZ"],
tiebreakerMaps: new MapPool([]),
});
for (let i = 0; i < mapList.length - 1; i++) {
assert.equal(mapList[i]!.mode, "SZ");
}
}
);
TournamentMapListGeneratorOneMode(
"Creates one mode map list from empty map lists",
() => {
const mapList = generateMaps({
teams: [
{
id: 1,
maps: new MapPool([]),
},
{
id: 2,
maps: new MapPool([]),
},
],
modesIncluded: ["SZ"],
tiebreakerMaps: new MapPool([]),
});
for (let i = 0; i < mapList.length - 1; i++) {
assert.equal(mapList[i]!.mode, "SZ");
}
}
);
TournamentMapListGeneratorOneMode(
"Creates all different maps from empty map lists",
() => {
const mapList = generateMaps({
teams: [
{
id: 1,
maps: new MapPool([]),
},
{
id: 2,
maps: new MapPool([]),
},
],
modesIncluded: ["SZ"],
tiebreakerMaps: new MapPool([]),
});
const stages = new Set(mapList.map(({ stageId }) => stageId));
assert.equal(stages.size, 5);
}
);
TournamentMapListGeneratorOneMode(
"Tiebreaker is always from the maps of the teams when possible",
() => {
for (let i = 1; i <= 10; i++) {
const mapList = generateMaps({
teams: [
{
id: 1,
maps: team1SZPicks,
},
{
id: 2,
maps: team2SZPicks,
},
],
modesIncluded: ["SZ"],
roundNumber: i,
tiebreakerMaps: new MapPool([]),
});
const last = mapList[mapList.length - 1];
assert.equal(last?.mode, "SZ");
assert.equal(last?.stageId, 9);
}
}
);
TournamentMapListGeneratorOneMode(
"Tiebreaker is from neither team's pool if no overlap",
() => {
const mapList = generateMaps({
teams: [
{
id: 1,
maps: team1SZPicks,
},
{
id: 2,
maps: team2SZPicksNoOverlap,
},
],
modesIncluded: ["SZ"],
tiebreakerMaps: new MapPool([]),
});
const last = mapList[mapList.length - 1];
assert.not.ok(
team1SZPicks.stageModePairs.some(
({ stageId }) => stageId === last?.stageId
)
);
assert.not.ok(
team2SZPicksNoOverlap.stageModePairs.some(
({ stageId }) => stageId === last?.stageId
)
);
}
);
TournamentMapListGeneratorOneMode("Handles worst case duplication", () => {
const mapList = generateMaps({
teams: [
{
id: 1,
maps: team1SZPicks,
},
{
id: 2,
maps: team1SZPicks,
},
],
modesIncluded: ["SZ"],
tiebreakerMaps: new MapPool([]),
bestOf: 7,
});
for (const [i, stage] of mapList.entries()) {
if (i === 6) {
assert.equal(stage?.source, "TIEBREAKER");
} else {
assert.equal(stage?.source, "BOTH");
}
}
});
TournamentMapListGeneratorOneMode("Handles one team submitted no maps", () => {
const mapList = generateMaps({
teams: [
{
id: 1,
maps: team1SZPicks,
},
{
id: 2,
maps: new MapPool([]),
},
],
modesIncluded: ["SZ"],
tiebreakerMaps: new MapPool([]),
});
for (const stage of mapList) {
assert.equal(stage.source, 1);
}
});
TournamentMapListGenerator.run();
TournamentMapListGeneratorOneMode.run();

View File

@@ -1,5 +1,5 @@
import invariant from "tiny-invariant";
import type { ModeShort, StageId } from "../in-game-lists";
import { type ModeShort, type StageId, stageIds } from "../in-game-lists";
import { DEFAULT_MAP_POOL } from "./constants";
import type {
TournamentMaplistInput,
@@ -16,7 +16,7 @@ export function createTournamentMapList(
input: TournamentMaplistInput
): Array<TournamentMapListMap> {
const { shuffle } = seededRandom(`${input.bracketType}-${input.roundNumber}`);
const stages = shuffle(resolveStages());
const stages = shuffle(resolveCommonStages());
const mapList: Array<ModeWithStageAndScore & { score: number }> = [];
const bestMapList: { maps?: Array<ModeWithStageAndScore>; score: number } = {
score: Infinity,
@@ -24,6 +24,7 @@ export function createTournamentMapList(
const usedStages = new Set<number>();
const backtrack = () => {
invariant(mapList.length <= input.bestOf, "mapList.length > input.bestOf");
const mapListScore = rateMapList();
if (typeof mapListScore === "number" && mapListScore < bestMapList.score) {
bestMapList.maps = [...mapList];
@@ -36,8 +37,10 @@ export function createTournamentMapList(
}
const stageList =
mapList.length < input.bestOf - 1
? stages
mapList.length < input.bestOf - 1 ||
// in 1 mode only the tiebreaker is not a thing
tournamentIsOneModeOnly()
? resolveOneModeOnlyStages()
: input.tiebreakerMaps.stageModePairs.map((p) => ({
...p,
score: 0,
@@ -62,7 +65,7 @@ export function createTournamentMapList(
throw new Error("couldn't generate maplist");
function resolveStages() {
function resolveCommonStages() {
const sorted = input.teams
.slice()
.sort((a, b) => a.id - b.id) as TournamentMaplistInput["teams"];
@@ -94,7 +97,7 @@ export function createTournamentMapList(
) {
// neither team submitted map, we go default
result.push(
...DEFAULT_MAP_POOL.stageModePairs.map((pair) => ({
...getDefaultMapPool().map((pair) => ({
...pair,
score: 0,
source: "DEFAULT" as const,
@@ -116,22 +119,90 @@ export function createTournamentMapList(
);
}
function resolveOneModeOnlyStages() {
if (utilizeOtherStageIdsInOneModeOnlyTournament()) {
// no overlap so we need to use a random map for tiebreaker
return shuffle([...stageIds])
.filter(
(stageId) =>
!input.teams[0].maps.hasStage(stageId) &&
!input.teams[1].maps.hasStage(stageId)
)
.map((stageId) => ({
stageId,
mode: input.modesIncluded[0]!,
score: 0,
source: "TIEBREAKER" as const,
}));
}
return stages;
}
function utilizeOtherStageIdsInOneModeOnlyTournament() {
if (mapList.length < input.bestOf - 1) return false;
if (
input.teams.every((team) => !team.maps.isEmpty()) &&
!input.teams[0].maps.overlaps(input.teams[1].maps)
) {
return true;
}
const teamsMapsLeftNotPicked =
[...input.teams[0].maps, ...input.teams[1].maps].filter(
(stage) =>
!mapList.some(
(map) => map.stageId === stage.stageId && map.mode === stage.mode
)
).length > 0;
if (!teamsMapsLeftNotPicked) return true;
return false;
}
function getDefaultMapPool() {
if (tournamentIsOneModeOnly()) {
const mode = input.modesIncluded[0]!;
return stageIds.map((id) => ({ mode, stageId: id }));
}
return DEFAULT_MAP_POOL.stageModePairs;
}
type StageValidatorInput = Pick<
ModeWithStageAndScore,
"score" | "stageId" | "mode"
>;
// adding rules here can achieve to things
// 1) adjust what kind of map list is generated
// 2) optimize the algorithm my eliminating subtrees from consideration
function stageIsOk(stage: StageValidatorInput, index: number) {
if (usedStages.has(index)) return false;
if (mapListAlreadyFull()) return false;
if (isEarlyModeRepeat(stage)) return false;
if (isNotFollowingModePattern(stage)) return false;
if (isMakingThingsUnfair(stage)) return false;
if (isStageRepeatWithoutBreak(stage)) return false;
if (isSecondPickBySameTeamInRow(stage)) return false;
if (wouldPreventTiebreaker(stage)) return false;
return true;
}
function tournamentIsOneModeOnly() {
return input.modesIncluded.length === 1;
}
function mapListAlreadyFull() {
return mapList.length === input.bestOf;
}
function isEarlyModeRepeat(stage: StageValidatorInput) {
if (tournamentIsOneModeOnly()) return false;
// all modes already appeared
if (mapList.length >= 4) return false;
@@ -147,6 +218,8 @@ export function createTournamentMapList(
}
function isNotFollowingModePattern(stage: StageValidatorInput) {
if (tournamentIsOneModeOnly()) return false;
// not all modes appeared yet
if (mapList.length < 4) return false;
@@ -191,6 +264,37 @@ export function createTournamentMapList(
return lastStage.score === stage.score;
}
function wouldPreventTiebreaker(stage: StageValidatorInput) {
// tiebreaker always guaranteed if not one mode
if (!tournamentIsOneModeOnly()) return false;
const commonMaps = input.teams[0].maps.stageModePairs.filter(
({ stageId, mode }) =>
input.teams[1].maps.stageModePairs.some(
(pair) => pair.stageId === stageId && pair.mode === mode
)
);
const newMapList = [...mapList, stage];
const newCommonMaps = commonMaps.filter(
({ stageId, mode }) =>
!newMapList.some(
(pair) => pair.stageId === stageId && pair.mode === mode
)
);
// there was at least one possible common map
// to pick as tiebreaker but it (or they) got picked too early
return (
commonMaps.length > 0 &&
// handles special case where both teams have the same maps in their pool
commonMaps.length !== input.teams[0].maps.stageModePairs.length &&
newCommonMaps.length === 0 &&
newMapList.length !== input.bestOf
);
}
function rateMapList() {
// not a full map list
if (mapList.length !== input.bestOf) return;
@@ -208,6 +312,36 @@ export function createTournamentMapList(
appearedMaps.set(stage.stageId, timesAppeared + 1);
}
if (!lastMapIsAGoodTieBreaker()) {
score += 1;
}
return score;
}
function lastMapIsAGoodTieBreaker() {
// guaranteed to be good if more than one mode
if (!tournamentIsOneModeOnly()) return true;
// specifically made tiebreaker map is considered good
const last = mapList[mapList.length - 1]!;
if (last.source === "TIEBREAKER") return true;
// we can't have a map from pools of both teams if both didn't submit maps
if (input.teams.some((team) => team.maps.stageModePairs.length === 0)) {
return true;
}
const tieBreakerMap = mapList[mapList.length - 1]!;
let appearanceCount = 0;
for (const team of input.teams) {
for (const stage of team.maps.stages) {
if (stage === tieBreakerMap.stageId) appearanceCount++;
}
}
return appearanceCount === 2;
}
}

View File

@@ -1,4 +1,4 @@
import type { ModeWithStage } from "../in-game-lists";
import type { ModeShort, ModeWithStage } from "../in-game-lists";
import type { MapPool } from "../map-pool-serializer";
export type BracketType =
@@ -23,6 +23,7 @@ export interface TournamentMaplistInput {
}
];
tiebreakerMaps: MapPool;
modesIncluded: ModeShort[];
}
export type TournamentMaplistSource =

View File

@@ -313,7 +313,7 @@ export function canEnableTOTools(user?: IsAdminUser) {
}
interface CanAdminCalendarTOTools {
user?: Pick<User, "id" | "discordId">;
user?: Pick<User, "id">;
event: Pick<CalendarEvent, "authorId">;
}
export function canAdminCalendarTOTools({

View File

@@ -65,6 +65,8 @@ import {
toArray,
} from "~/utils/zod";
import { Tags } from "./components/Tags";
import type { RankedModeShort } from "~/modules/in-game-lists";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
const MIN_DATE = new Date(Date.UTC(2015, 4, 28));
@@ -128,6 +130,7 @@ const newCalendarEventActionSchema = z.object({
),
pool: z.string().optional(),
toToolsEnabled: z.preprocess(checkboxValueToBoolean, z.boolean()),
toToolsMode: z.enum(["ALL", "SZ", "TC", "RM", "CB"]).optional(),
});
export const action: ActionFunction = async ({ request }) => {
@@ -154,8 +157,12 @@ export const action: ActionFunction = async ({ request }) => {
: data.tags,
badges: data.badges ?? [],
toToolsEnabled: canEnableTOTools(user) ? Number(data.toToolsEnabled) : 0,
toToolsMode:
rankedModesShort.find((mode) => mode === data.toToolsMode) ?? null,
};
// TODO: messing with these and "one mode selection" can cause problems when teams
// have already chosend maps for their pools
const deserializedMaps = (() => {
if (!data.pool) return;
@@ -592,13 +599,31 @@ function TOToolsAndMapPool() {
const [checked, setChecked] = React.useState(
Boolean(eventToEdit?.toToolsEnabled)
);
const [mode, setMode] = React.useState<"ALL" | RankedModeShort>("ALL");
return (
<>
{canEnableTOTools(user) && (
<TOToolsEnabler checked={checked} setChecked={setChecked} />
)}
{checked ? <CounterPickMapPoolSection /> : <MapPoolSection />}
{checked ? (
<>
<select
className="calendar-new__select"
onChange={(e) => setMode(e.target.value as RankedModeShort)}
name="toToolsMode"
>
<option value="ALL">All modes</option>
<option value="SZ">SZ only</option>
<option value="TC">TC only</option>
<option value="RM">RM only</option>
<option value="CB">CB only</option>
</select>
{mode === "ALL" ? <CounterPickMapPoolSection /> : null}
</>
) : (
<MapPoolSection />
)}
</>
);
}

View File

@@ -32,6 +32,8 @@ const staticAssetsUrl = ({
}) =>
`https://raw.githubusercontent.com/Sendouc/sendou-ink-assets/main/${folder}/${fileName}`;
export const SENDOU_INK_BASE_URL = "https://sendou.ink";
const USER_SUBMITTED_IMAGE_ROOT = "https://sendou.nyc3.digitaloceanspaces.com";
export const userSubmittedImage = (fileName: string) =>
`${USER_SUBMITTED_IMAGE_ROOT}/${fileName}`;
@@ -175,6 +177,16 @@ export const calendarEditPage = (eventId?: number) =>
export const calendarReportWinnersPage = (eventId: number) =>
`/calendar/${eventId}/report-winners`;
export const toToolsPage = (eventId: number) => `/to/${eventId}`;
export const toToolsRegisterPage = (eventId: number) =>
`/to/${eventId}/register`;
export const toToolsMapsPage = (eventId: number) => `/to/${eventId}/maps`;
export const toToolsJoinPage = ({
eventId,
inviteCode,
}: {
eventId: number;
inviteCode: string;
}) => `/to/${eventId}/join?code=${inviteCode}`;
export const mapsPage = (eventId?: MapPoolMap["calendarEventId"]) =>
`/maps${eventId ? `?eventId=${eventId}` : ""}`;

View File

@@ -0,0 +1,12 @@
module.exports.up = function (db) {
db.prepare(
/* sql */ `alter table "CalendarEvent" add "isBeforeStart" integer default 1`
).run();
db.prepare(
/* sql */ `alter table "CalendarEvent" add "toToolsMode" text`
).run();
db.prepare(
/* sql */ `alter table "TournamentTeam" drop column "friendCode"`
).run();
};

View File

@@ -2,13 +2,15 @@
"tabs.info": "Info",
"tabs.teams": "Teams ({{count}})",
"tabs.admin": "Admin",
"tabs.register": "Register",
"tabs.maps": "Maps",
"pre.footerNote": "Note: you can change your map pool and roster as many times as you want before the tournament starts.",
"pre.deleteTeam": "Delete team",
"preview": "Preview map list generator (admin only)",
"pre.steps.register": "1. Register on",
"pre.steps.register.summary": "Enter team name you register with",
"pre.steps.register.summary": "Enter the team name you registered with",
"pre.steps.mapPool": "2. Map pool",
"pre.steps.mapPool.explanation": "You can play without selecting a map pool but then your opponent gets to decide what maps get played. Tie-breaker maps marked in blue.",
"pre.steps.mapPool.summary": "Pick your team's maps",

View File

@@ -22,6 +22,8 @@ module.exports = {
);
route("/to/:id/teams", "features/tournament/routes/to.$id.teams.tsx");
route("/to/:id/join", "features/tournament/routes/to.$id.join.tsx");
route("/to/:id/admin", "features/tournament/routes/to.$id.admin.tsx");
route("/to/:id/maps", "features/tournament/routes/to.$id.maps.tsx");
});
route("/privacy-policy", "features/info/routes/privacy-policy.tsx");