mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-24 06:58:10 -05:00
TO Tools (#1077)
* Add DB tables
* Toggle TO Tools in new calendar event page
* TO tools page initial
* Add counter pick map pool UI
* Save tie breaker map pool
* Save team name
* Layout initial
* Load users own team
* Make team name input required
* Rename team
* Divide to sections
* Submit team map pool
* New style for counter pick map pool section expand
* Fix tiebreaker map pool not saved when new event made
* Split to many forms
* According for team name
* Small UI consistency tweaks
* Add explanation to tie breaker maps
* Remove redundant prop
* Fix new calendar event todos
* Use required hidden input component in new build page
* Fix to tools page showing even when toToolsEnabled = 0
* Delete team
* Map list generation tests initial
* Add tournament map list generation tests
* First version of map list generation
* Add seeded RNG
* Rearrange files
* Generation with strats initial
* Default map pool + allow one team not to have any maps
* Implement map generation via backtracking
* Make order of stages irrelevant
* Add one more TODO
* Seed
* Fixes
* Tournament map list generator initial
* More functional maplist
* Fix any
* Persist in search params initial
* Add date to calendar seed
* Revert "Persist in search params initial"
This reverts commit f01a9e6982.
* Allow admin to start tournament
* Rate maplist instead of optimal / suboptimal
* Add fallback if map list generation errors out
* Hide TO Tools if not admin
* Submit team roster and delete members
* Teams page
* Give roster s p a c e
* Clear user combobox on sent + layout tweaks
* Gracefully handle updating after tournament has started
* Add title
* Persist map list in search params
* Add i18n
This commit is contained in:
parent
f6990e93eb
commit
ecd5a2a2f7
|
|
@ -1,16 +1,51 @@
|
|||
import clsx from "clsx";
|
||||
import type * as React from "react";
|
||||
import { AlertIcon } from "./icons/Alert";
|
||||
import { ErrorIcon } from "./icons/Error";
|
||||
import { CheckmarkIcon } from "./icons/Checkmark";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
|
||||
export type AlertVariation = "INFO" | "WARNING" | "ERROR" | "SUCCESS";
|
||||
|
||||
export function Alert({
|
||||
children,
|
||||
textClassName,
|
||||
alertClassName,
|
||||
variation = "INFO",
|
||||
tiny = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
textClassName?: string;
|
||||
alertClassName?: string;
|
||||
variation?: AlertVariation;
|
||||
tiny?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="alert">
|
||||
<AlertIcon /> <div className={textClassName}>{children}</div>
|
||||
<div
|
||||
className={clsx("alert", alertClassName, {
|
||||
tiny,
|
||||
warning: variation === "WARNING",
|
||||
error: variation === "ERROR",
|
||||
success: variation === "SUCCESS",
|
||||
})}
|
||||
>
|
||||
<Icon variation={variation} />{" "}
|
||||
<div className={textClassName}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Icon({ variation }: { variation: AlertVariation }) {
|
||||
switch (variation) {
|
||||
case "INFO":
|
||||
return <AlertIcon />;
|
||||
case "WARNING":
|
||||
return <AlertIcon />;
|
||||
case "ERROR":
|
||||
return <ErrorIcon />;
|
||||
case "SUCCESS":
|
||||
return <CheckmarkIcon />;
|
||||
default:
|
||||
assertUnreachable(variation);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
app/components/DetailsSummary.tsx
Normal file
22
app/components/DetailsSummary.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import clsx from "clsx";
|
||||
import type * as React from "react";
|
||||
|
||||
export function Details({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <details className={className}>{children}</details>;
|
||||
}
|
||||
|
||||
export function Summary({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <summary className={clsx("summary", className)}>{children}</summary>;
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ import { MapPoolEventsCombobox } from "./Combobox";
|
|||
|
||||
export type MapPoolSelectorProps = {
|
||||
mapPool: MapPool;
|
||||
preselectedMapPool?: MapPool;
|
||||
handleRemoval?: () => void;
|
||||
handleMapPoolChange: (
|
||||
mapPool: MapPool,
|
||||
|
|
@ -29,15 +30,26 @@ export type MapPoolSelectorProps = {
|
|||
className?: string;
|
||||
recentEvents?: SerializedMapPoolEvent[];
|
||||
initialEvent?: Pick<CalendarEvent, "id" | "name">;
|
||||
title?: string;
|
||||
noTitle?: boolean;
|
||||
modesToInclude?: ModeShort[];
|
||||
info?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function MapPoolSelector({
|
||||
mapPool,
|
||||
preselectedMapPool,
|
||||
handleMapPoolChange,
|
||||
handleRemoval,
|
||||
className,
|
||||
recentEvents,
|
||||
initialEvent,
|
||||
title,
|
||||
noTitle = false,
|
||||
modesToInclude,
|
||||
info,
|
||||
footer,
|
||||
}: MapPoolSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
|
@ -99,41 +111,52 @@ export function MapPoolSelector({
|
|||
assertType<never, typeof template>();
|
||||
};
|
||||
|
||||
const includeFancyControls = Boolean(recentEvents);
|
||||
|
||||
return (
|
||||
<fieldset className={className}>
|
||||
<legend>{t("maps.mapPool")}</legend>
|
||||
<div className="stack horizontal sm justify-end">
|
||||
{handleRemoval && (
|
||||
<Button variant="minimal" onClick={handleRemoval}>
|
||||
{t("actions.remove")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="minimal-destructive"
|
||||
disabled={mapPool.isEmpty()}
|
||||
onClick={handleClear}
|
||||
>
|
||||
{t("actions.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="stack md">
|
||||
<div className="maps__template-selection">
|
||||
<MapPoolTemplateSelect
|
||||
value={template}
|
||||
handleChange={handleTemplateChange}
|
||||
recentEvents={recentEvents}
|
||||
/>
|
||||
{template === "event" && (
|
||||
<TemplateEventSelection
|
||||
initialEvent={initialSerializedEvent}
|
||||
handleEventChange={handleMapPoolChange}
|
||||
/>
|
||||
{!noTitle && <legend>{title ?? t("maps.mapPool")}</legend>}
|
||||
{includeFancyControls && (
|
||||
<div className="stack horizontal sm justify-end">
|
||||
{handleRemoval && (
|
||||
<Button variant="minimal" onClick={handleRemoval}>
|
||||
{t("actions.remove")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="minimal-destructive"
|
||||
disabled={mapPool.isEmpty()}
|
||||
onClick={handleClear}
|
||||
>
|
||||
{t("actions.clear")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="stack md">
|
||||
{includeFancyControls && (
|
||||
<div className="maps__template-selection">
|
||||
<MapPoolTemplateSelect
|
||||
value={template}
|
||||
handleChange={handleTemplateChange}
|
||||
recentEvents={recentEvents}
|
||||
/>
|
||||
{template === "event" && (
|
||||
<TemplateEventSelection
|
||||
initialEvent={initialSerializedEvent}
|
||||
handleEventChange={handleMapPoolChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{info}
|
||||
<MapPoolStages
|
||||
mapPool={mapPool}
|
||||
handleMapPoolChange={handleStageModesChange}
|
||||
includeFancyControls={includeFancyControls}
|
||||
modesToInclude={modesToInclude}
|
||||
preselectedMapPool={preselectedMapPool}
|
||||
/>
|
||||
{footer}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
|
|
@ -142,11 +165,17 @@ export function MapPoolSelector({
|
|||
export type MapPoolStagesProps = {
|
||||
mapPool: MapPool;
|
||||
handleMapPoolChange?: (newMapPool: MapPool) => void;
|
||||
includeFancyControls?: boolean;
|
||||
modesToInclude?: ModeShort[];
|
||||
preselectedMapPool?: MapPool;
|
||||
};
|
||||
|
||||
export function MapPoolStages({
|
||||
mapPool,
|
||||
handleMapPoolChange,
|
||||
includeFancyControls = true,
|
||||
modesToInclude,
|
||||
preselectedMapPool,
|
||||
}: MapPoolStagesProps) {
|
||||
const { t } = useTranslation(["game-misc", "common"]);
|
||||
|
||||
|
|
@ -224,53 +253,67 @@ export function MapPoolStages({
|
|||
{t(`game-misc:STAGE_${stageId}`)}
|
||||
</div>
|
||||
<div className="maps__mode-buttons-container">
|
||||
{modes.map((mode) => {
|
||||
const selected = mapPool.parsed[mode.short].includes(stageId);
|
||||
{modes
|
||||
.filter(
|
||||
(mode) =>
|
||||
!modesToInclude || modesToInclude.includes(mode.short)
|
||||
)
|
||||
.map((mode) => {
|
||||
const selected = mapPool.has({ stageId, mode: mode.short });
|
||||
|
||||
if (isPresentational && !selected) return null;
|
||||
if (isPresentational && selected) {
|
||||
return (
|
||||
<Image
|
||||
key={mode.short}
|
||||
className={clsx("maps__mode", {
|
||||
selected,
|
||||
})}
|
||||
title={t(`game-misc:MODE_LONG_${mode.short}`)}
|
||||
alt={t(`game-misc:MODE_LONG_${mode.short}`)}
|
||||
path={modeImageUrl(mode.short)}
|
||||
width={33}
|
||||
height={33}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const preselected = preselectedMapPool?.has({
|
||||
stageId,
|
||||
mode: mode.short,
|
||||
});
|
||||
|
||||
if (isPresentational && !selected) return null;
|
||||
if (isPresentational && selected) {
|
||||
return (
|
||||
<Image
|
||||
<button
|
||||
key={mode.short}
|
||||
className={clsx("maps__mode", {
|
||||
className={clsx("maps__mode-button", "outline-theme", {
|
||||
selected,
|
||||
preselected,
|
||||
})}
|
||||
onClick={() =>
|
||||
handleModeChange?.({ mode: mode.short, stageId })
|
||||
}
|
||||
type="button"
|
||||
title={t(`game-misc:MODE_LONG_${mode.short}`)}
|
||||
alt={t(`game-misc:MODE_LONG_${mode.short}`)}
|
||||
path={modeImageUrl(mode.short)}
|
||||
width={33}
|
||||
height={33}
|
||||
/>
|
||||
aria-describedby={`${id}-stage-name-${stageId}`}
|
||||
aria-pressed={selected}
|
||||
disabled={preselected}
|
||||
>
|
||||
<Image
|
||||
className={clsx("maps__mode", {
|
||||
selected,
|
||||
preselected,
|
||||
})}
|
||||
alt={t(`game-misc:MODE_LONG_${mode.short}`)}
|
||||
path={modeImageUrl(mode.short)}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={mode.short}
|
||||
className={clsx("maps__mode-button", "outline-theme", {
|
||||
selected,
|
||||
})}
|
||||
onClick={() =>
|
||||
handleModeChange?.({ mode: mode.short, stageId })
|
||||
}
|
||||
type="button"
|
||||
title={t(`game-misc:MODE_LONG_${mode.short}`)}
|
||||
aria-describedby={`${id}-stage-name-${stageId}`}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
<Image
|
||||
className={clsx("maps__mode", {
|
||||
selected,
|
||||
})}
|
||||
alt={t(`game-misc:MODE_LONG_${mode.short}`)}
|
||||
path={modeImageUrl(mode.short)}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
})}
|
||||
{!isPresentational &&
|
||||
includeFancyControls &&
|
||||
(mapPool.hasStage(stageId) ? (
|
||||
<Button
|
||||
key="clear"
|
||||
|
|
|
|||
21
app/components/RequiredHiddenInput.tsx
Normal file
21
app/components/RequiredHiddenInput.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
export function RequiredHiddenInput({
|
||||
value,
|
||||
isValid,
|
||||
name,
|
||||
}: {
|
||||
value: string;
|
||||
isValid: boolean;
|
||||
name: string;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
className="hidden-input-with-validation"
|
||||
name={name}
|
||||
value={isValid ? value : []}
|
||||
// empty onChange is because otherwise it will give a React error in console
|
||||
// readOnly can't be set as then validation is not active
|
||||
onChange={() => null}
|
||||
required
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,16 +5,22 @@ export function Toggle({
|
|||
checked,
|
||||
setChecked,
|
||||
tiny,
|
||||
id,
|
||||
name,
|
||||
}: {
|
||||
checked: boolean;
|
||||
setChecked: (checked: boolean) => void;
|
||||
tiny?: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
}) {
|
||||
return (
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={setChecked}
|
||||
className={clsx("toggle", { checked, tiny })}
|
||||
id={id}
|
||||
name={name}
|
||||
>
|
||||
<span className={clsx("toggle-dot", { checked, tiny })} />
|
||||
</Switch>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,15 @@ export const CALENDAR_EVENT_RESULT = {
|
|||
MAX_PLAYER_NAME_LENGTH: 100,
|
||||
} as const;
|
||||
|
||||
export const TOURNAMENT = {
|
||||
TEAM_NAME_MAX_LENGTH: 64,
|
||||
COUNTERPICK_MAPS_PER_MODE: 2,
|
||||
COUNTERPICK_MAX_STAGE_REPEAT: 2,
|
||||
TEAM_MIN_MEMBERS_FOR_FULL: 4,
|
||||
TEAM_MAX_MEMBERS: 6,
|
||||
AVAILABLE_BEST_OF: [3, 5, 7] as const,
|
||||
} as const;
|
||||
|
||||
export const BUILD = {
|
||||
TITLE_MIN_LENGTH: 1,
|
||||
TITLE_MAX_LENGTH: 100,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import * as plusVotes from "./models/plusVotes/queries.server";
|
|||
import * as badges from "./models/badges/queries.server";
|
||||
import * as calendarEvents from "./models/calendar/queries.server";
|
||||
import * as builds from "./models/builds/queries.server";
|
||||
import * as tournaments from "./models/tournaments/queries.server";
|
||||
|
||||
export const db = {
|
||||
users,
|
||||
|
|
@ -12,4 +13,5 @@ export const db = {
|
|||
badges,
|
||||
calendarEvents,
|
||||
builds,
|
||||
tournaments,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ insert into
|
|||
"tags",
|
||||
"description",
|
||||
"discordInviteCode",
|
||||
"bracketUrl"
|
||||
"bracketUrl",
|
||||
"toToolsEnabled"
|
||||
)
|
||||
values
|
||||
(
|
||||
|
|
@ -14,5 +15,6 @@ values
|
|||
@tags,
|
||||
@description,
|
||||
@discordInviteCode,
|
||||
@bracketUrl
|
||||
) returning *
|
||||
@bracketUrl,
|
||||
@toToolsEnabled
|
||||
) returning *
|
||||
|
|
|
|||
4
app/db/models/calendar/createTieBreakerMapPoolMap.sql
Normal file
4
app/db/models/calendar/createTieBreakerMapPoolMap.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
insert into
|
||||
"MapPoolMap" ("tieBreakerCalendarEventId", "stageId", "mode")
|
||||
values
|
||||
(@calendarEventId, @stageId, @mode)
|
||||
|
|
@ -2,3 +2,4 @@ delete from
|
|||
"MapPoolMap"
|
||||
where
|
||||
"calendarEventId" = @calendarEventId
|
||||
or "tieBreakerCalendarEventId" = @calendarEventId
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ select
|
|||
"CalendarEvent"."bracketUrl",
|
||||
"CalendarEvent"."tags",
|
||||
"CalendarEvent"."participantCount",
|
||||
"CalendarEvent"."toToolsEnabled",
|
||||
"User"."id" as "authorId",
|
||||
exists (
|
||||
select
|
||||
|
|
@ -28,4 +29,4 @@ from
|
|||
where
|
||||
"CalendarEvent"."id" = @id
|
||||
order by
|
||||
"CalendarEventDate"."startTime" asc
|
||||
"CalendarEventDate"."startTime" asc
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
select
|
||||
"stageId",
|
||||
"mode"
|
||||
from
|
||||
"MapPoolMap"
|
||||
where
|
||||
"tieBreakerCalendarEventId" = @calendarEventId
|
||||
|
|
@ -35,9 +35,11 @@ import recentWinnersSql from "./recentWinners.sql";
|
|||
import upcomingEventsSql from "./upcomingEvents.sql";
|
||||
import createMapPoolMapSql from "./createMapPoolMap.sql";
|
||||
import deleteMapPoolMapsSql from "./deleteMapPoolMaps.sql";
|
||||
import createTieBreakerMapPoolMapSql from "./createTieBreakerMapPoolMap.sql";
|
||||
import findMapPoolByEventIdSql from "./findMapPoolByEventId.sql";
|
||||
import findRecentMapPoolsByAuthorIdSql from "./findRecentMapPoolsByAuthorId.sql";
|
||||
import findAllEventsWithMapPoolsSql from "./findAllEventsWithMapPools.sql";
|
||||
import findTieBreakerMapPoolByEventIdSql from "./findTieBreakerMapPoolByEventId.sql";
|
||||
|
||||
const createStm = sql.prepare(createSql);
|
||||
const updateStm = sql.prepare(updateSql);
|
||||
|
|
@ -47,7 +49,13 @@ const createBadgeStm = sql.prepare(createBadgeSql);
|
|||
const deleteBadgesByEventIdStm = sql.prepare(deleteBadgesByEventIdSql);
|
||||
const createMapPoolMapStm = sql.prepare(createMapPoolMapSql);
|
||||
const deleteMapPoolMapsStm = sql.prepare(deleteMapPoolMapsSql);
|
||||
const createTieBreakerMapPoolMapStm = sql.prepare(
|
||||
createTieBreakerMapPoolMapSql
|
||||
);
|
||||
const findMapPoolByEventIdStm = sql.prepare(findMapPoolByEventIdSql);
|
||||
const findTieBreakerMapPoolByEventIdtm = sql.prepare(
|
||||
findTieBreakerMapPoolByEventIdSql
|
||||
);
|
||||
|
||||
export type CreateArgs = Pick<
|
||||
CalendarEvent,
|
||||
|
|
@ -57,6 +65,7 @@ export type CreateArgs = Pick<
|
|||
| "description"
|
||||
| "discordInviteCode"
|
||||
| "bracketUrl"
|
||||
| "toToolsEnabled"
|
||||
> & {
|
||||
startTimes: Array<CalendarEventDate["startTime"]>;
|
||||
badges: Array<CalendarEventBadge["badgeId"]>;
|
||||
|
|
@ -85,17 +94,19 @@ export const create = sql.transaction(
|
|||
});
|
||||
}
|
||||
|
||||
for (const mapPoolArgs of mapPoolMaps) {
|
||||
createMapPoolMapStm.run({
|
||||
calendarEventId: createdEvent.id,
|
||||
...mapPoolArgs,
|
||||
});
|
||||
}
|
||||
upsertMapPool({
|
||||
eventId: createdEvent.id,
|
||||
mapPoolMaps,
|
||||
toToolsEnabled: calendarEventArgs.toToolsEnabled,
|
||||
});
|
||||
|
||||
return createdEvent.id;
|
||||
}
|
||||
);
|
||||
|
||||
export type Update = Omit<CreateArgs, "authorId"> & {
|
||||
eventId: CalendarEvent["id"];
|
||||
};
|
||||
export const update = sql.transaction(
|
||||
({
|
||||
startTimes,
|
||||
|
|
@ -103,7 +114,7 @@ export const update = sql.transaction(
|
|||
eventId,
|
||||
mapPoolMaps = [],
|
||||
...calendarEventArgs
|
||||
}: Omit<CreateArgs, "authorId"> & { eventId: CalendarEvent["id"] }) => {
|
||||
}: Update) => {
|
||||
updateStm.run({ ...calendarEventArgs, eventId });
|
||||
|
||||
deleteDatesByEventIdStm.run({ eventId });
|
||||
|
|
@ -122,7 +133,32 @@ export const update = sql.transaction(
|
|||
});
|
||||
}
|
||||
|
||||
deleteMapPoolMapsStm.run({ calendarEventId: eventId });
|
||||
upsertMapPool({
|
||||
eventId,
|
||||
mapPoolMaps,
|
||||
toToolsEnabled: calendarEventArgs.toToolsEnabled,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
function upsertMapPool({
|
||||
eventId,
|
||||
mapPoolMaps,
|
||||
toToolsEnabled,
|
||||
}: {
|
||||
eventId: Update["eventId"];
|
||||
mapPoolMaps: NonNullable<Update["mapPoolMaps"]>;
|
||||
toToolsEnabled: Update["toToolsEnabled"];
|
||||
}) {
|
||||
deleteMapPoolMapsStm.run({ calendarEventId: eventId });
|
||||
if (toToolsEnabled) {
|
||||
for (const mapPoolArgs of mapPoolMaps) {
|
||||
createTieBreakerMapPoolMapStm.run({
|
||||
calendarEventId: eventId,
|
||||
...mapPoolArgs,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (const mapPoolArgs of mapPoolMaps) {
|
||||
createMapPoolMapStm.run({
|
||||
calendarEventId: eventId,
|
||||
|
|
@ -130,7 +166,7 @@ export const update = sql.transaction(
|
|||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const updateParticipantsCountStm = sql.prepare(updateParticipantsCountSql);
|
||||
const deleteResultTeamsByEventIdStm = sql.prepare(
|
||||
|
|
@ -378,6 +414,7 @@ export function findById(id: CalendarEvent["id"]) {
|
|||
| "tags"
|
||||
| "authorId"
|
||||
| "participantCount"
|
||||
| "toToolsEnabled"
|
||||
> &
|
||||
Pick<CalendarEventDate, "startTime" | "eventId"> &
|
||||
Pick<
|
||||
|
|
@ -503,3 +540,11 @@ export function findAllEventsWithMapPools() {
|
|||
serializedMapPool: MapPool.serialize(JSON.parse(row.mapPool)),
|
||||
}));
|
||||
}
|
||||
|
||||
export function findTieBreakerMapPoolByEventId(
|
||||
calendarEventId: string | number
|
||||
) {
|
||||
return findTieBreakerMapPoolByEventIdtm.all({ calendarEventId }) as Array<
|
||||
Pick<MapPoolMap, "mode" | "stageId">
|
||||
>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ set
|
|||
"tags" = @tags,
|
||||
"description" = @description,
|
||||
"discordInviteCode" = @discordInviteCode,
|
||||
"bracketUrl" = @bracketUrl
|
||||
"bracketUrl" = @bracketUrl,
|
||||
"toToolsEnabled" = @toToolsEnabled
|
||||
where
|
||||
"id" = @eventId
|
||||
"id" = @eventId
|
||||
|
|
|
|||
4
app/db/models/tournaments/addCounterpickMap.sql
Normal file
4
app/db/models/tournaments/addCounterpickMap.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
insert into
|
||||
"MapPoolMap" ("tournamentTeamId", "stageId", "mode")
|
||||
values
|
||||
(@tournamentTeamId, @stageId, @mode)
|
||||
4
app/db/models/tournaments/addTeam.sql
Normal file
4
app/db/models/tournaments/addTeam.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
insert into
|
||||
"TournamentTeam" ("name", "createdAt", "calendarEventId")
|
||||
values
|
||||
(@name, @createdAt, @calendarEventId) returning *;
|
||||
9
app/db/models/tournaments/addTeamMember.sql
Normal file
9
app/db/models/tournaments/addTeamMember.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
insert into
|
||||
"TournamentTeamMember" (
|
||||
"tournamentTeamId",
|
||||
"userId",
|
||||
"isOwner",
|
||||
"createdAt"
|
||||
)
|
||||
values
|
||||
(@tournamentTeamId, @userId, @isOwner, @createdAt);
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
delete from
|
||||
"MapPoolMap"
|
||||
where
|
||||
"tournamentTeamId" = @tournamentTeamId
|
||||
5
app/db/models/tournaments/deleteTeamMember.sql
Normal file
5
app/db/models/tournaments/deleteTeamMember.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
delete from
|
||||
"TournamentTeamMember"
|
||||
where
|
||||
"userId" = @userId
|
||||
and "tournamentTeamId" = @tournamentTeamId;
|
||||
4
app/db/models/tournaments/deleteTournamentTeam.sql
Normal file
4
app/db/models/tournaments/deleteTournamentTeam.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
delete from
|
||||
"TournamentTeam"
|
||||
where
|
||||
"id" = @id
|
||||
14
app/db/models/tournaments/findByIdentifier.sql
Normal file
14
app/db/models/tournaments/findByIdentifier.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
select
|
||||
"CalendarEvent"."id",
|
||||
"CalendarEvent"."bracketUrl",
|
||||
"CalendarEvent"."isBeforeStart",
|
||||
"CalendarEvent"."authorId",
|
||||
"CalendarEvent"."name"
|
||||
from
|
||||
"CalendarEvent"
|
||||
where
|
||||
(
|
||||
"id" = @identifier
|
||||
or "customUrl" = @identifier
|
||||
)
|
||||
and "CalendarEvent"."toToolsEnabled" = 1
|
||||
44
app/db/models/tournaments/findTeamsByEventId.sql
Normal file
44
app/db/models/tournaments/findTeamsByEventId.sql
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
with "TeamWithMembers" as (
|
||||
select
|
||||
"TournamentTeam"."id",
|
||||
"TournamentTeam"."name",
|
||||
json_group_array(
|
||||
json_object(
|
||||
'userId',
|
||||
"TournamentTeamMember"."userId",
|
||||
'isOwner',
|
||||
"TournamentTeamMember"."isOwner",
|
||||
'discordName',
|
||||
"User"."discordName",
|
||||
'discordId',
|
||||
"User"."discordId",
|
||||
'discordAvatar',
|
||||
"User"."discordAvatar"
|
||||
)
|
||||
) as "members"
|
||||
from
|
||||
"TournamentTeam"
|
||||
left join "TournamentTeamMember" on "TournamentTeamMember"."tournamentTeamId" = "TournamentTeam"."id"
|
||||
left join "User" on "User"."id" = "TournamentTeamMember"."userId"
|
||||
where
|
||||
"TournamentTeam"."calendarEventId" = @calendarEventId
|
||||
group by
|
||||
"TournamentTeam"."id"
|
||||
)
|
||||
select
|
||||
"TeamWithMembers".*,
|
||||
json_group_array(
|
||||
json_object(
|
||||
'stageId',
|
||||
"MapPoolMap"."stageId",
|
||||
'mode',
|
||||
"MapPoolMap"."mode"
|
||||
)
|
||||
) as "mapPool"
|
||||
from
|
||||
"TeamWithMembers"
|
||||
left join "MapPoolMap" on "MapPoolMap"."tournamentTeamId" = "TeamWithMembers"."id"
|
||||
group by
|
||||
"TeamWithMembers"."id"
|
||||
order by
|
||||
"TeamWithMembers"."name" asc
|
||||
154
app/db/models/tournaments/queries.server.ts
Normal file
154
app/db/models/tournaments/queries.server.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { sql } from "~/db/sql";
|
||||
import type {
|
||||
CalendarEvent,
|
||||
MapPoolMap,
|
||||
TournamentTeam,
|
||||
TournamentTeamMember,
|
||||
User,
|
||||
} from "~/db/types";
|
||||
import { databaseCreatedAt } from "~/utils/dates";
|
||||
import type { MapPool } from "~/modules/map-pool-serializer";
|
||||
import { parseDBJsonArray } from "~/utils/sql";
|
||||
|
||||
import findByIdentifierSql from "./findByIdentifier.sql";
|
||||
import addTeamSql from "./addTeam.sql";
|
||||
import addTeamMemberSql from "./addTeamMember.sql";
|
||||
import findTeamsByEventIdSql from "./findTeamsByEventId.sql";
|
||||
import renameTeamSql from "./renameTeam.sql";
|
||||
import addCounterpickMapSql from "./addCounterpickMap.sql";
|
||||
import deleteCounterpickMapsByTeamIdSql from "./deleteCounterpickMapsByTeamId.sql";
|
||||
import deleteTournamentTeamSql from "./deleteTournamentTeam.sql";
|
||||
import updateIsBeforeStartSql from "./updateIsBeforeStart.sql";
|
||||
import deleteTeamMemberSql from "./deleteTeamMember.sql";
|
||||
|
||||
const findByIdentifierStm = sql.prepare(findByIdentifierSql);
|
||||
const addTeamStm = sql.prepare(addTeamSql);
|
||||
const addTeamMemberStm = sql.prepare(addTeamMemberSql);
|
||||
const findTeamsByEventIdStm = sql.prepare(findTeamsByEventIdSql);
|
||||
const renameTeamStm = sql.prepare(renameTeamSql);
|
||||
const addCounterpickMapStm = sql.prepare(addCounterpickMapSql);
|
||||
const deleteCounterpickMapsByTeamIdStm = sql.prepare(
|
||||
deleteCounterpickMapsByTeamIdSql
|
||||
);
|
||||
const deleteTournamentTeamStm = sql.prepare(deleteTournamentTeamSql);
|
||||
const updateIsBeforeStartStm = sql.prepare(updateIsBeforeStartSql);
|
||||
const deleteTeamMemberStm = sql.prepare(deleteTeamMemberSql);
|
||||
|
||||
type FindByIdentifier = Pick<
|
||||
CalendarEvent,
|
||||
"bracketUrl" | "isBeforeStart" | "id" | "authorId" | "name"
|
||||
> | null;
|
||||
export function findByIdentifier(identifier: string | number) {
|
||||
return findByIdentifierStm.get({ identifier }) as FindByIdentifier;
|
||||
}
|
||||
|
||||
export const addTeam = sql.transaction(
|
||||
({
|
||||
name,
|
||||
ownerId,
|
||||
calendarEventId,
|
||||
}: {
|
||||
name: TournamentTeam["name"];
|
||||
ownerId: User["id"];
|
||||
calendarEventId: CalendarEvent["id"];
|
||||
}) => {
|
||||
const createdAt = databaseCreatedAt();
|
||||
const addedTeam = addTeamStm.get({
|
||||
name,
|
||||
createdAt,
|
||||
calendarEventId,
|
||||
}) as TournamentTeam;
|
||||
|
||||
addTeamMemberStm.run({
|
||||
tournamentTeamId: addedTeam.id,
|
||||
userId: ownerId,
|
||||
isOwner: 1,
|
||||
createdAt,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export function addTeamMember({
|
||||
tournamentTeamId,
|
||||
userId,
|
||||
}: {
|
||||
tournamentTeamId: TournamentTeam["id"];
|
||||
userId: User["id"];
|
||||
}) {
|
||||
addTeamMemberStm.run({
|
||||
tournamentTeamId,
|
||||
userId,
|
||||
isOwner: 0,
|
||||
createdAt: databaseCreatedAt(),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteTeamMember({
|
||||
tournamentTeamId,
|
||||
userId,
|
||||
}: {
|
||||
tournamentTeamId: TournamentTeam["id"];
|
||||
userId: User["id"];
|
||||
}) {
|
||||
deleteTeamMemberStm.run({
|
||||
tournamentTeamId,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
export interface FindTeamsByEventIdItem {
|
||||
id: TournamentTeam["id"];
|
||||
name: TournamentTeam["name"];
|
||||
members: Array<
|
||||
Pick<TournamentTeamMember, "userId" | "isOwner"> &
|
||||
Pick<User, "discordAvatar" | "discordId" | "discordName">
|
||||
>;
|
||||
mapPool: Array<Pick<MapPoolMap, "mode" | "stageId">>;
|
||||
}
|
||||
export type FindTeamsByEventId = Array<FindTeamsByEventIdItem>;
|
||||
export function findTeamsByEventId(calendarEventId: CalendarEvent["id"]) {
|
||||
const rows = findTeamsByEventIdStm.all({ calendarEventId });
|
||||
|
||||
return rows.map((row) => {
|
||||
return {
|
||||
...row,
|
||||
members: parseDBJsonArray(row.members),
|
||||
mapPool: parseDBJsonArray(row.mapPool),
|
||||
};
|
||||
}) as FindTeamsByEventId;
|
||||
}
|
||||
|
||||
export function renameTeam({ id, name }: Pick<TournamentTeam, "id" | "name">) {
|
||||
renameTeamStm.run({ id, name });
|
||||
}
|
||||
|
||||
export const upsertCounterpickMaps = sql.transaction(
|
||||
({
|
||||
tournamentTeamId,
|
||||
mapPool,
|
||||
}: {
|
||||
tournamentTeamId: TournamentTeam["id"];
|
||||
mapPool: MapPool;
|
||||
}) => {
|
||||
deleteCounterpickMapsByTeamIdStm.run({ tournamentTeamId });
|
||||
|
||||
for (const { stageId, mode } of mapPool.stageModePairs) {
|
||||
addCounterpickMapStm.run({
|
||||
tournamentTeamId,
|
||||
stageId,
|
||||
mode,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export function deleteTournamentTeam(id: TournamentTeam["id"]) {
|
||||
deleteTournamentTeamStm.run({ id });
|
||||
}
|
||||
|
||||
export function updateIsBeforeStart({
|
||||
id,
|
||||
isBeforeStart,
|
||||
}: Pick<CalendarEvent, "id" | "isBeforeStart">) {
|
||||
updateIsBeforeStartStm.run({ id, isBeforeStart });
|
||||
}
|
||||
6
app/db/models/tournaments/renameTeam.sql
Normal file
6
app/db/models/tournaments/renameTeam.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
update
|
||||
"TournamentTeam"
|
||||
set
|
||||
"name" = @name
|
||||
where
|
||||
"id" = @id;
|
||||
6
app/db/models/tournaments/updateIsBeforeStart.sql
Normal file
6
app/db/models/tournaments/updateIsBeforeStart.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
update
|
||||
"CalendarEvent"
|
||||
set
|
||||
"isBeforeStart" = @isBeforeStart
|
||||
where
|
||||
"id" = @id;
|
||||
221
app/db/seed.ts
221
app/db/seed.ts
|
|
@ -7,13 +7,16 @@ import { db } from "~/db";
|
|||
import { sql } from "~/db/sql";
|
||||
import {
|
||||
abilities,
|
||||
type AbilityType,
|
||||
clothesGearIds,
|
||||
headGearIds,
|
||||
mainWeaponIds,
|
||||
modesShort,
|
||||
shoesGearIds,
|
||||
mainWeaponIds,
|
||||
type StageId,
|
||||
type AbilityType,
|
||||
} from "~/modules/in-game-lists";
|
||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import { MapPool } from "~/modules/map-pool-serializer";
|
||||
import {
|
||||
lastCompletedVoting,
|
||||
nextNonCompletedVoting,
|
||||
|
|
@ -45,6 +48,9 @@ const basicSeeds = [
|
|||
calendarEvents,
|
||||
calendarEventBadges,
|
||||
calendarEventResults,
|
||||
calendarEventWithToTools,
|
||||
calendarEventWithToToolsTieBreakerMapPool,
|
||||
calendarEventWithToToolsTeams,
|
||||
adminBuilds,
|
||||
manySplattershotBuilds,
|
||||
];
|
||||
|
|
@ -60,6 +66,9 @@ export function seed() {
|
|||
function wipeDB() {
|
||||
const tablesToDelete = [
|
||||
"Build",
|
||||
"TournamentTeamMember",
|
||||
"MapPoolMap",
|
||||
"TournamentTeam",
|
||||
"CalendarEventDate",
|
||||
"CalendarEventResultPlayer",
|
||||
"CalendarEventResultTeam",
|
||||
|
|
@ -361,11 +370,15 @@ function patrons() {
|
|||
}
|
||||
}
|
||||
|
||||
function userIdsInRandomOrder() {
|
||||
return sql
|
||||
function userIdsInRandomOrder(adminLast = false) {
|
||||
const rows = sql
|
||||
.prepare(`select "id" from "User" order by random()`)
|
||||
.all()
|
||||
.map((u) => u.id) as number[];
|
||||
|
||||
if (!adminLast) return rows;
|
||||
|
||||
return [...rows.filter((id) => id !== 1), 1];
|
||||
}
|
||||
|
||||
function calendarEvents() {
|
||||
|
|
@ -531,6 +544,206 @@ function calendarEventResults() {
|
|||
}
|
||||
}
|
||||
|
||||
const TO_TOOLS_CALENDAR_EVENT_ID = 201;
|
||||
function calendarEventWithToTools() {
|
||||
sql
|
||||
.prepare(
|
||||
`
|
||||
insert into "CalendarEvent" (
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"discordInviteCode",
|
||||
"bracketUrl",
|
||||
"authorId",
|
||||
"toToolsEnabled",
|
||||
"isBeforeStart"
|
||||
) values (
|
||||
$id,
|
||||
$name,
|
||||
$description,
|
||||
$discordInviteCode,
|
||||
$bracketUrl,
|
||||
$authorId,
|
||||
$toToolsEnabled,
|
||||
$isBeforeStart
|
||||
)
|
||||
`
|
||||
)
|
||||
.run({
|
||||
id: TO_TOOLS_CALENDAR_EVENT_ID,
|
||||
name: `${capitalize(faker.word.adjective())} ${capitalize(
|
||||
faker.word.noun()
|
||||
)}`,
|
||||
description: faker.lorem.paragraph(),
|
||||
discordInviteCode: faker.lorem.word(),
|
||||
bracketUrl: faker.internet.url(),
|
||||
authorId: 1,
|
||||
toToolsEnabled: 1,
|
||||
isBeforeStart: 0,
|
||||
});
|
||||
|
||||
sql
|
||||
.prepare(
|
||||
`
|
||||
insert into "CalendarEventDate" (
|
||||
"eventId",
|
||||
"startTime"
|
||||
) values (
|
||||
$eventId,
|
||||
$startTime
|
||||
)
|
||||
`
|
||||
)
|
||||
.run({
|
||||
eventId: TO_TOOLS_CALENDAR_EVENT_ID,
|
||||
startTime: dateToDatabaseTimestamp(new Date()),
|
||||
});
|
||||
}
|
||||
|
||||
const tiebreakerPicks = new MapPool([
|
||||
{ mode: "SZ", stageId: 1 },
|
||||
{ mode: "TC", stageId: 2 },
|
||||
{ mode: "RM", stageId: 3 },
|
||||
{ mode: "CB", stageId: 4 },
|
||||
]);
|
||||
function calendarEventWithToToolsTieBreakerMapPool() {
|
||||
for (const { mode, stageId } of tiebreakerPicks.stageModePairs) {
|
||||
sql
|
||||
.prepare(
|
||||
`
|
||||
insert into "MapPoolMap" (
|
||||
"tieBreakerCalendarEventId",
|
||||
"stageId",
|
||||
"mode"
|
||||
) values (
|
||||
$tieBreakerCalendarEventId,
|
||||
$stageId,
|
||||
$mode
|
||||
)
|
||||
`
|
||||
)
|
||||
.run({
|
||||
tieBreakerCalendarEventId: TO_TOOLS_CALENDAR_EVENT_ID,
|
||||
stageId,
|
||||
mode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const names = Array.from(
|
||||
new Set(new Array(100).fill(null).map(() => faker.music.songName()))
|
||||
);
|
||||
const availableStages: StageId[] = [1, 2, 3, 4, 6, 7, 8, 10, 11];
|
||||
const availablePairs = rankedModesShort
|
||||
.flatMap((mode) =>
|
||||
availableStages.map((stageId) => ({ mode, stageId: stageId }))
|
||||
)
|
||||
.filter((pair) => !tiebreakerPicks.has(pair));
|
||||
function calendarEventWithToToolsTeams() {
|
||||
const userIds = userIdsInRandomOrder(true);
|
||||
for (let id = 1; id <= 40; id++) {
|
||||
sql
|
||||
.prepare(
|
||||
`
|
||||
insert into "TournamentTeam" (
|
||||
"id",
|
||||
"name",
|
||||
"createdAt",
|
||||
"calendarEventId"
|
||||
) values (
|
||||
$id,
|
||||
$name,
|
||||
$createdAt,
|
||||
$calendarEventId
|
||||
)
|
||||
`
|
||||
)
|
||||
.run({
|
||||
id,
|
||||
name: names.pop(),
|
||||
createdAt: dateToDatabaseTimestamp(new Date()),
|
||||
calendarEventId: TO_TOOLS_CALENDAR_EVENT_ID,
|
||||
});
|
||||
|
||||
for (
|
||||
let i = 0;
|
||||
i < faker.helpers.arrayElement([1, 2, 3, 4, 4, 4, 4, 4, 4, 5, 6, 7, 8]);
|
||||
i++
|
||||
) {
|
||||
sql
|
||||
.prepare(
|
||||
`
|
||||
insert into "TournamentTeamMember" (
|
||||
"tournamentTeamId",
|
||||
"userId",
|
||||
"isOwner",
|
||||
"createdAt"
|
||||
) values (
|
||||
$tournamentTeamId,
|
||||
$userId,
|
||||
$isOwner,
|
||||
$createdAt
|
||||
)
|
||||
`
|
||||
)
|
||||
.run({
|
||||
tournamentTeamId: id,
|
||||
userId: userIds.pop()!,
|
||||
isOwner: i === 0 ? 1 : 0,
|
||||
createdAt: dateToDatabaseTimestamp(new Date()),
|
||||
});
|
||||
}
|
||||
|
||||
if (Math.random() < 0.8 || id === 1) {
|
||||
const shuffledPairs = shuffle(availablePairs.slice());
|
||||
|
||||
let SZ = 0;
|
||||
let TC = 0;
|
||||
let RM = 0;
|
||||
let CB = 0;
|
||||
const stageUsedCounts: Partial<Record<StageId, number>> = {};
|
||||
|
||||
for (const pair of shuffledPairs) {
|
||||
if (pair.mode === "SZ" && SZ >= 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;
|
||||
|
||||
stageUsedCounts[pair.stageId] =
|
||||
(stageUsedCounts[pair.stageId] ?? 0) + 1;
|
||||
|
||||
sql
|
||||
.prepare(
|
||||
`
|
||||
insert into "MapPoolMap" (
|
||||
"tournamentTeamId",
|
||||
"stageId",
|
||||
"mode"
|
||||
) values (
|
||||
$tournamentTeamId,
|
||||
$stageId,
|
||||
$mode
|
||||
)
|
||||
`
|
||||
)
|
||||
.run({
|
||||
tournamentTeamId: id,
|
||||
stageId: pair.stageId,
|
||||
mode: pair.mode,
|
||||
});
|
||||
|
||||
if (pair.mode === "SZ") SZ++;
|
||||
if (pair.mode === "TC") TC++;
|
||||
if (pair.mode === "RM") RM++;
|
||||
if (pair.mode === "CB") CB++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const randomAbility = (legalTypes: AbilityType[]) => {
|
||||
const randomOrderAbilities = shuffle([...abilities]);
|
||||
|
||||
|
|
|
|||
|
|
@ -100,7 +100,11 @@ export interface CalendarEvent {
|
|||
discordUrl: string | null;
|
||||
bracketUrl: string;
|
||||
participantCount: number | null;
|
||||
mapPoolId?: number;
|
||||
customUrl: string | null;
|
||||
/** Is tournament tools page visible */
|
||||
toToolsEnabled: number;
|
||||
/** In tournament tools, can teams change their maps and rosters? */
|
||||
isBeforeStart: number;
|
||||
}
|
||||
|
||||
export type CalendarEventTag = keyof typeof allTags;
|
||||
|
|
@ -156,7 +160,24 @@ export interface BuildAbility {
|
|||
}
|
||||
|
||||
export interface MapPoolMap {
|
||||
calendarEventId?: number;
|
||||
calendarEventId: number | null; // Part of tournament's map pool
|
||||
tournamentTeamId: number | null; // Part of team's map pool
|
||||
tieBreakerCalendarEventId: number | null; // Part of the tournament's tiebreaker pool
|
||||
stageId: StageId;
|
||||
mode: ModeShort;
|
||||
}
|
||||
|
||||
export interface TournamentTeam {
|
||||
id: number;
|
||||
name: string;
|
||||
createdAt: number;
|
||||
seed: number | null;
|
||||
calendarEventId: number;
|
||||
}
|
||||
|
||||
export interface TournamentTeamMember {
|
||||
tournamentTeamId: number;
|
||||
userId: number;
|
||||
isOwner: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
|
|
|||
43
app/hooks/useSearchParamState.tsx
Normal file
43
app/hooks/useSearchParamState.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { useSearchParams } from "@remix-run/react";
|
||||
import * as React from "react";
|
||||
|
||||
/** State backed search params. Used when you want to update search params without triggering navigation (runs loaders, rerenders the whole page extra time) */
|
||||
export function useSearchParamState<T>({
|
||||
defaultValue,
|
||||
name,
|
||||
revive,
|
||||
}: {
|
||||
defaultValue: T;
|
||||
name: string;
|
||||
revive: (value: string) => T | null | undefined;
|
||||
}) {
|
||||
const [initialSearchParams] = useSearchParams();
|
||||
const [state, setState] = React.useState<T>(resolveInitialState());
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(newValue: T) => {
|
||||
setState(newValue);
|
||||
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
searchParams.set(name, String(newValue));
|
||||
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
`${window.location.pathname}?${String(searchParams)}`
|
||||
);
|
||||
},
|
||||
[name]
|
||||
);
|
||||
|
||||
return [state, handleChange] as const;
|
||||
|
||||
function resolveInitialState() {
|
||||
const value = initialSearchParams.get(name);
|
||||
if (value === null || value === undefined) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return revive(value) ?? defaultValue;
|
||||
}
|
||||
}
|
||||
|
|
@ -7,3 +7,4 @@ export const modes = [
|
|||
] as const;
|
||||
|
||||
export const modesShort = modes.map((mode) => mode.short);
|
||||
export const rankedModesShort = modesShort.slice(1);
|
||||
|
|
|
|||
|
|
@ -63,6 +63,20 @@ export class MapPool {
|
|||
);
|
||||
}
|
||||
|
||||
get stages() {
|
||||
return Object.values(this.parsed).flat();
|
||||
}
|
||||
|
||||
get stageModePairs() {
|
||||
return Object.entries(this.parsed).flatMap(([mode, stages]) =>
|
||||
stages.map((stageId) => ({ mode: mode as ModeShort, stageId }))
|
||||
);
|
||||
}
|
||||
|
||||
has({ stageId, mode }: { stageId: StageId; mode: ModeShort }) {
|
||||
return this.parsed[mode].includes(stageId);
|
||||
}
|
||||
|
||||
hasMode(mode: ModeShort): boolean {
|
||||
return this.parsed[mode].length > 0;
|
||||
}
|
||||
|
|
|
|||
12
app/modules/tournament-map-list-generator/constants.ts
Normal file
12
app/modules/tournament-map-list-generator/constants.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { MapPool } from "../map-pool-serializer";
|
||||
|
||||
export const DEFAULT_MAP_POOL = new MapPool([
|
||||
{ mode: "SZ", stageId: 10 },
|
||||
{ mode: "SZ", stageId: 1 },
|
||||
{ mode: "TC", stageId: 2 },
|
||||
{ mode: "TC", stageId: 6 },
|
||||
{ mode: "RM", stageId: 10 },
|
||||
{ mode: "RM", stageId: 2 },
|
||||
{ mode: "CB", stageId: 8 },
|
||||
{ mode: "CB", stageId: 3 },
|
||||
]);
|
||||
349
app/modules/tournament-map-list-generator/generation.test.ts
Normal file
349
app/modules/tournament-map-list-generator/generation.test.ts
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
import { suite } from "uvu";
|
||||
import * as assert from "uvu/assert";
|
||||
import { createTournamentMapList } from ".";
|
||||
import { rankedModesShort } from "../in-game-lists/modes";
|
||||
import { MapPool } from "../map-pool-serializer";
|
||||
import type { TournamentMaplistInput } from "./types";
|
||||
|
||||
const TournamentMapListGenerator = suite("Tournament map list generator");
|
||||
|
||||
const team1Picks = new MapPool([
|
||||
{ mode: "SZ", stageId: 4 },
|
||||
{ mode: "SZ", stageId: 5 },
|
||||
{ mode: "TC", stageId: 5 },
|
||||
{ mode: "TC", stageId: 6 },
|
||||
{ mode: "RM", stageId: 7 },
|
||||
{ mode: "RM", stageId: 8 },
|
||||
{ mode: "CB", stageId: 9 },
|
||||
{ mode: "CB", stageId: 10 },
|
||||
]);
|
||||
const team2Picks = new MapPool([
|
||||
{ mode: "SZ", stageId: 11 },
|
||||
{ mode: "SZ", stageId: 9 },
|
||||
{ mode: "TC", stageId: 2 },
|
||||
{ mode: "TC", stageId: 8 },
|
||||
{ mode: "RM", stageId: 7 },
|
||||
{ mode: "RM", stageId: 1 },
|
||||
{ mode: "CB", stageId: 2 },
|
||||
{ mode: "CB", stageId: 3 },
|
||||
]);
|
||||
const tiebreakerPicks = new MapPool([
|
||||
{ mode: "SZ", stageId: 1 },
|
||||
{ mode: "TC", stageId: 11 },
|
||||
{ mode: "RM", stageId: 3 },
|
||||
{ mode: "CB", stageId: 4 },
|
||||
]);
|
||||
|
||||
const generateMaps = ({
|
||||
bestOf = 5,
|
||||
bracketType = "SE",
|
||||
roundNumber = 3,
|
||||
teams = [
|
||||
{
|
||||
id: 1,
|
||||
maps: team1Picks,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: team2Picks,
|
||||
},
|
||||
],
|
||||
tiebreakerMaps = tiebreakerPicks,
|
||||
}: Partial<TournamentMaplistInput> = {}) => {
|
||||
return createTournamentMapList({
|
||||
bestOf,
|
||||
bracketType,
|
||||
roundNumber,
|
||||
teams,
|
||||
tiebreakerMaps,
|
||||
});
|
||||
};
|
||||
|
||||
TournamentMapListGenerator("Modes are spread evenly", () => {
|
||||
const mapList = generateMaps();
|
||||
const modes = new Set(rankedModesShort);
|
||||
|
||||
assert.equal(mapList.length, 5);
|
||||
|
||||
for (const [i, { mode }] of mapList.entries()) {
|
||||
if (!modes.has(mode)) {
|
||||
assert.equal(i, 4, "Repeated mode early");
|
||||
assert.equal(mode, mapList[0]!.mode, "1st and 5th mode are not the same");
|
||||
}
|
||||
|
||||
modes.delete(mode);
|
||||
}
|
||||
});
|
||||
|
||||
TournamentMapListGenerator("Equal picks", () => {
|
||||
let our = 0;
|
||||
let their = 0;
|
||||
let tiebreaker = 0;
|
||||
|
||||
const mapList = generateMaps();
|
||||
|
||||
for (const { stageId, mode } of mapList) {
|
||||
if (team1Picks.has({ stageId, mode })) {
|
||||
our++;
|
||||
}
|
||||
|
||||
if (team2Picks.has({ stageId, mode })) {
|
||||
their++;
|
||||
}
|
||||
|
||||
if (tiebreakerPicks.has({ stageId, mode })) {
|
||||
tiebreaker++;
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(our, their);
|
||||
assert.equal(tiebreaker, 1);
|
||||
});
|
||||
|
||||
TournamentMapListGenerator("No stage repeats in optimal case", () => {
|
||||
const mapList = generateMaps();
|
||||
|
||||
const stages = new Set(mapList.map(({ stageId }) => stageId));
|
||||
|
||||
assert.equal(stages.size, 5);
|
||||
});
|
||||
|
||||
TournamentMapListGenerator(
|
||||
"Always generates same maplist given same input",
|
||||
() => {
|
||||
const mapList1 = generateMaps();
|
||||
const mapList2 = generateMaps();
|
||||
|
||||
assert.equal(mapList1.length, 5);
|
||||
|
||||
for (let i = 0; i < mapList1.length; i++) {
|
||||
assert.equal(mapList1[i]!.stageId, mapList2[i]!.stageId);
|
||||
assert.equal(mapList1[i]!.mode, mapList2[i]!.mode);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
TournamentMapListGenerator(
|
||||
"Order of team doesn't matter regarding what maplist gets created",
|
||||
() => {
|
||||
const mapList1 = generateMaps();
|
||||
const mapList2 = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 2,
|
||||
maps: team2Picks,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
maps: team1Picks,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(mapList1.length, 5);
|
||||
|
||||
for (let i = 0; i < mapList1.length; i++) {
|
||||
assert.equal(mapList1[i]!.stageId, mapList2[i]!.stageId);
|
||||
assert.equal(mapList1[i]!.mode, mapList2[i]!.mode);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
TournamentMapListGenerator(
|
||||
"Order of maps in the list doesn't matter regarding what maplist gets created",
|
||||
() => {
|
||||
const mapList1 = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: team1Picks,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: team2Picks,
|
||||
},
|
||||
],
|
||||
});
|
||||
const mapList2 = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: team1Picks,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: new MapPool(team2Picks.stageModePairs.slice().reverse()),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(mapList1.length, 5);
|
||||
|
||||
for (let i = 0; i < mapList1.length; i++) {
|
||||
assert.equal(mapList1[i]!.stageId, mapList2[i]!.stageId);
|
||||
assert.equal(mapList1[i]!.mode, mapList2[i]!.mode);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const duplicationPicks = new MapPool([
|
||||
{ mode: "SZ", stageId: 4 },
|
||||
{ mode: "SZ", stageId: 5 },
|
||||
{ mode: "TC", stageId: 4 },
|
||||
{ mode: "TC", stageId: 5 },
|
||||
{ mode: "RM", stageId: 6 },
|
||||
{ mode: "RM", stageId: 7 },
|
||||
{ mode: "CB", stageId: 6 },
|
||||
{ mode: "CB", stageId: 7 },
|
||||
]);
|
||||
const duplicationTiebreaker = new MapPool([
|
||||
{ mode: "SZ", stageId: 7 },
|
||||
{ mode: "TC", stageId: 6 },
|
||||
{ mode: "RM", stageId: 5 },
|
||||
{ mode: "CB", stageId: 4 },
|
||||
]);
|
||||
|
||||
TournamentMapListGenerator(
|
||||
"Uses other teams maps if one didn't submit maplist",
|
||||
() => {
|
||||
const mapList = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: new MapPool([]),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: team2Picks,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(mapList.length, 5);
|
||||
|
||||
for (let i = 0; i < mapList.length - 1; i++) {
|
||||
// map belongs to team 2 map list
|
||||
const map = mapList[i];
|
||||
assert.ok(map);
|
||||
|
||||
team2Picks.has({ mode: map.mode, stageId: map.stageId });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
TournamentMapListGenerator(
|
||||
"Creates map list even if neither team submitted maps",
|
||||
() => {
|
||||
const mapList = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: new MapPool([]),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: new MapPool([]),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(mapList.length, 5);
|
||||
}
|
||||
);
|
||||
|
||||
TournamentMapListGenerator("Handles worst case with duplication", () => {
|
||||
const maplist = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: duplicationPicks,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: duplicationPicks,
|
||||
},
|
||||
],
|
||||
bestOf: 7,
|
||||
tiebreakerMaps: duplicationTiebreaker,
|
||||
});
|
||||
|
||||
assert.equal(maplist.length, 7);
|
||||
|
||||
// all stages appear
|
||||
const stages = new Set(maplist.map(({ stageId }) => stageId));
|
||||
assert.equal(stages.size, 4);
|
||||
|
||||
// no consecutive stage replays
|
||||
for (let i = 0; i < maplist.length - 1; i++) {
|
||||
assert.not.equal(maplist[i]!.stageId, maplist[i + 1]!.stageId);
|
||||
}
|
||||
});
|
||||
|
||||
const team2PicksWithSomeDuplication = new MapPool([
|
||||
{ mode: "SZ", stageId: 4 },
|
||||
{ mode: "SZ", stageId: 11 },
|
||||
{ mode: "TC", stageId: 5 },
|
||||
{ mode: "TC", stageId: 6 },
|
||||
{ mode: "RM", stageId: 7 },
|
||||
{ mode: "RM", stageId: 2 },
|
||||
{ mode: "CB", stageId: 9 },
|
||||
{ mode: "CB", stageId: 10 },
|
||||
]);
|
||||
|
||||
TournamentMapListGenerator("Keeps things fair when overlap", () => {
|
||||
const mapList = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: team1Picks,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: team2PicksWithSomeDuplication,
|
||||
},
|
||||
],
|
||||
bestOf: 7,
|
||||
});
|
||||
|
||||
assert.equal(mapList.length, 7);
|
||||
|
||||
let team1PicksAppeared = 0;
|
||||
let team2PicksAppeared = 0;
|
||||
|
||||
for (const { stageId, mode } of mapList) {
|
||||
if (team1Picks.has({ stageId, mode })) {
|
||||
team1PicksAppeared++;
|
||||
}
|
||||
|
||||
if (team2PicksWithSomeDuplication.has({ stageId, mode })) {
|
||||
team2PicksAppeared++;
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(team1PicksAppeared, team2PicksAppeared);
|
||||
});
|
||||
|
||||
TournamentMapListGenerator("No map picked by same team twice in row", () => {
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const mapList = generateMaps({
|
||||
teams: [
|
||||
{
|
||||
id: 1,
|
||||
maps: team1Picks,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
maps: team2Picks,
|
||||
},
|
||||
],
|
||||
roundNumber: i,
|
||||
});
|
||||
|
||||
for (let j = 0; j < mapList.length - 1; j++) {
|
||||
if (typeof mapList[j]!.source !== "number") continue;
|
||||
assert.not.equal(mapList[j]!.source, mapList[j + 1]!.source);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
TournamentMapListGenerator.run();
|
||||
7
app/modules/tournament-map-list-generator/index.ts
Normal file
7
app/modules/tournament-map-list-generator/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export { createTournamentMapList } from "./tournament-map-list";
|
||||
export type {
|
||||
BracketType,
|
||||
TournamentMaplistInput,
|
||||
TournamentMaplistSource,
|
||||
TournamentMapListMap,
|
||||
} from "./types";
|
||||
213
app/modules/tournament-map-list-generator/tournament-map-list.ts
Normal file
213
app/modules/tournament-map-list-generator/tournament-map-list.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import invariant from "tiny-invariant";
|
||||
import type { ModeShort, StageId } from "../in-game-lists";
|
||||
import { DEFAULT_MAP_POOL } from "./constants";
|
||||
import type {
|
||||
TournamentMaplistInput,
|
||||
TournamentMapListMap,
|
||||
TournamentMaplistSource,
|
||||
} from "./types";
|
||||
import { seededRandom } from "./utils";
|
||||
|
||||
type ModeWithStageAndScore = TournamentMapListMap & { score: number };
|
||||
|
||||
const OPTIMAL_MAPLIST_SCORE = 0;
|
||||
|
||||
export function createTournamentMapList(
|
||||
input: TournamentMaplistInput
|
||||
): Array<TournamentMapListMap> {
|
||||
const { shuffle } = seededRandom(`${input.bracketType}-${input.roundNumber}`);
|
||||
const stages = shuffle(resolveStages());
|
||||
const mapList: Array<ModeWithStageAndScore & { score: number }> = [];
|
||||
const bestMapList: { maps?: Array<ModeWithStageAndScore>; score: number } = {
|
||||
score: Infinity,
|
||||
};
|
||||
const usedStages = new Set<number>();
|
||||
|
||||
const backtrack = () => {
|
||||
const mapListScore = rateMapList();
|
||||
if (typeof mapListScore === "number" && mapListScore < bestMapList.score) {
|
||||
bestMapList.maps = [...mapList];
|
||||
bestMapList.score = mapListScore;
|
||||
}
|
||||
|
||||
// There can't be better map list than this
|
||||
if (bestMapList.score === OPTIMAL_MAPLIST_SCORE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stageList =
|
||||
mapList.length < input.bestOf - 1
|
||||
? stages
|
||||
: input.tiebreakerMaps.stageModePairs.map((p) => ({
|
||||
...p,
|
||||
score: 0,
|
||||
source: "TIEBREAKER" as const,
|
||||
}));
|
||||
|
||||
for (const [i, stage] of stageList.entries()) {
|
||||
if (!stageIsOk(stage, i)) continue;
|
||||
mapList.push(stage);
|
||||
usedStages.add(i);
|
||||
|
||||
backtrack();
|
||||
|
||||
usedStages.delete(i);
|
||||
mapList.pop();
|
||||
}
|
||||
};
|
||||
|
||||
backtrack();
|
||||
|
||||
if (bestMapList.maps) return bestMapList.maps;
|
||||
|
||||
throw new Error("couldn't generate maplist");
|
||||
|
||||
function resolveStages() {
|
||||
const sorted = input.teams
|
||||
.slice()
|
||||
.sort((a, b) => a.id - b.id) as TournamentMaplistInput["teams"];
|
||||
|
||||
const result = sorted[0].maps.stageModePairs.map((pair) => ({
|
||||
...pair,
|
||||
score: 1,
|
||||
source: sorted[0].id as TournamentMaplistSource,
|
||||
}));
|
||||
|
||||
for (const stage of sorted[1].maps.stageModePairs) {
|
||||
const alreadyIncludedStage = result.find(
|
||||
(alreadyIncludedStage) =>
|
||||
alreadyIncludedStage.stageId === stage.stageId &&
|
||||
alreadyIncludedStage.mode === stage.mode
|
||||
);
|
||||
|
||||
if (alreadyIncludedStage) {
|
||||
alreadyIncludedStage.score = 0;
|
||||
alreadyIncludedStage.source = "BOTH";
|
||||
} else {
|
||||
result.push({ ...stage, score: -1, source: sorted[1].id });
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
input.teams[0].maps.stages.length === 0 &&
|
||||
input.teams[1].maps.stages.length === 0
|
||||
) {
|
||||
// neither team submitted map, we go default
|
||||
result.push(
|
||||
...DEFAULT_MAP_POOL.stageModePairs.map((pair) => ({
|
||||
...pair,
|
||||
score: 0,
|
||||
source: "DEFAULT" as const,
|
||||
}))
|
||||
);
|
||||
} else if (
|
||||
input.teams[0].maps.stages.length === 0 ||
|
||||
input.teams[1].maps.stages.length === 0
|
||||
) {
|
||||
// let's set it up for later that if one team doesn't have stages set
|
||||
// we can make a maplist consisting of only stages from the team that did submit
|
||||
for (const stageObj of result) {
|
||||
stageObj.score = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return result.sort((a, b) =>
|
||||
`${a.stageId}-${a.mode}`.localeCompare(`${b.stageId}-${b.mode}`)
|
||||
);
|
||||
}
|
||||
|
||||
type StageValidatorInput = Pick<
|
||||
ModeWithStageAndScore,
|
||||
"score" | "stageId" | "mode"
|
||||
>;
|
||||
function stageIsOk(stage: StageValidatorInput, index: number) {
|
||||
if (usedStages.has(index)) 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;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isEarlyModeRepeat(stage: StageValidatorInput) {
|
||||
// all modes already appeared
|
||||
if (mapList.length >= 4) return false;
|
||||
|
||||
if (
|
||||
mapList.some(
|
||||
(alreadyIncludedStage) => alreadyIncludedStage.mode === stage.mode
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isNotFollowingModePattern(stage: StageValidatorInput) {
|
||||
// not all modes appeared yet
|
||||
if (mapList.length < 4) return false;
|
||||
|
||||
let previousModeShouldBe: ModeShort | undefined;
|
||||
for (let i = 0; i < mapList.length; i++) {
|
||||
if (mapList[i]!.mode === stage.mode) {
|
||||
if (i === 0) {
|
||||
previousModeShouldBe = mapList[mapList.length - 1]!.mode;
|
||||
} else {
|
||||
previousModeShouldBe = mapList[i - 1]!.mode;
|
||||
}
|
||||
}
|
||||
}
|
||||
invariant(previousModeShouldBe, "Couldn't resolve maplist pattern");
|
||||
|
||||
return mapList[mapList.length - 1]!.mode !== previousModeShouldBe;
|
||||
}
|
||||
|
||||
// don't allow making two picks from one team in row
|
||||
function isMakingThingsUnfair(stage: StageValidatorInput) {
|
||||
const score = mapList.reduce((acc, cur) => acc + cur.score, 0);
|
||||
const newScore = score + stage.score;
|
||||
|
||||
if (score !== 0 && newScore !== 0) return true;
|
||||
if (newScore !== 0 && mapList.length + 1 === input.bestOf) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isStageRepeatWithoutBreak(stage: StageValidatorInput) {
|
||||
const lastStage = mapList[mapList.length - 1];
|
||||
if (!lastStage) return false;
|
||||
|
||||
return lastStage.stageId === stage.stageId;
|
||||
}
|
||||
|
||||
function isSecondPickBySameTeamInRow(stage: StageValidatorInput) {
|
||||
const lastStage = mapList[mapList.length - 1];
|
||||
if (!lastStage) return false;
|
||||
if (stage.score === 0) return false;
|
||||
|
||||
return lastStage.score === stage.score;
|
||||
}
|
||||
|
||||
function rateMapList() {
|
||||
// not a full map list
|
||||
if (mapList.length !== input.bestOf) return;
|
||||
|
||||
let score = OPTIMAL_MAPLIST_SCORE;
|
||||
|
||||
const appearedMaps = new Map<StageId, number>();
|
||||
for (const stage of mapList) {
|
||||
const timesAppeared = appearedMaps.get(stage.stageId) ?? 0;
|
||||
|
||||
if (timesAppeared > 0) {
|
||||
score += timesAppeared;
|
||||
}
|
||||
|
||||
appearedMaps.set(stage.stageId, timesAppeared + 1);
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
}
|
||||
36
app/modules/tournament-map-list-generator/types.ts
Normal file
36
app/modules/tournament-map-list-generator/types.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type { ModeWithStage } from "../in-game-lists";
|
||||
import type { MapPool } from "../map-pool-serializer";
|
||||
|
||||
export type BracketType =
|
||||
| "GROUPS"
|
||||
| "SE"
|
||||
| "DE_WINNERS"
|
||||
| "DE_LOSERS"
|
||||
| "SWISS";
|
||||
|
||||
export interface TournamentMaplistInput {
|
||||
bestOf: 3 | 5 | 7;
|
||||
roundNumber: number;
|
||||
bracketType: BracketType;
|
||||
teams: [
|
||||
{
|
||||
id: number;
|
||||
maps: MapPool;
|
||||
},
|
||||
{
|
||||
id: number;
|
||||
maps: MapPool;
|
||||
}
|
||||
];
|
||||
tiebreakerMaps: MapPool;
|
||||
}
|
||||
|
||||
export type TournamentMaplistSource =
|
||||
| number
|
||||
| "DEFAULT"
|
||||
| "TIEBREAKER"
|
||||
| "BOTH";
|
||||
|
||||
export type TournamentMapListMap = ModeWithStage & {
|
||||
source: TournamentMaplistSource;
|
||||
};
|
||||
64
app/modules/tournament-map-list-generator/utils.ts
Normal file
64
app/modules/tournament-map-list-generator/utils.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// https://stackoverflow.com/a/68523152
|
||||
|
||||
function cyrb128(str: string) {
|
||||
let h1 = 1779033703,
|
||||
h2 = 3144134277,
|
||||
h3 = 1013904242,
|
||||
h4 = 2773480762;
|
||||
for (let i = 0, k; i < str.length; i++) {
|
||||
k = str.charCodeAt(i);
|
||||
h1 = h2 ^ Math.imul(h1 ^ k, 597399067);
|
||||
h2 = h3 ^ Math.imul(h2 ^ k, 2869860233);
|
||||
h3 = h4 ^ Math.imul(h3 ^ k, 951274213);
|
||||
h4 = h1 ^ Math.imul(h4 ^ k, 2716044179);
|
||||
}
|
||||
h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067);
|
||||
h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233);
|
||||
h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213);
|
||||
h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179);
|
||||
return [
|
||||
(h1 ^ h2 ^ h3 ^ h4) >>> 0,
|
||||
(h2 ^ h1) >>> 0,
|
||||
(h3 ^ h1) >>> 0,
|
||||
(h4 ^ h1) >>> 0,
|
||||
];
|
||||
}
|
||||
|
||||
function mulberry32(a: number) {
|
||||
return function () {
|
||||
var t = (a += 0x6d2b79f5);
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
export const seededRandom = (seed: string) => {
|
||||
const rng = mulberry32(cyrb128(seed)[0]!);
|
||||
|
||||
const rnd = (lo: number, hi?: number, defaultHi = 1) => {
|
||||
if (hi === undefined) {
|
||||
hi = lo === undefined ? defaultHi : lo;
|
||||
lo = 0;
|
||||
}
|
||||
|
||||
return rng() * (hi - lo) + lo;
|
||||
};
|
||||
|
||||
const rndInt = (lo: number, hi?: number) => Math.floor(rnd(lo, hi, 2));
|
||||
|
||||
const shuffle = <T>(o: T[]) => {
|
||||
const a = o.slice();
|
||||
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = rndInt(i + 1);
|
||||
const x = a[i];
|
||||
a[i] = a[j]!;
|
||||
a[j] = x!;
|
||||
}
|
||||
|
||||
return a;
|
||||
};
|
||||
|
||||
return { rnd, rndInt, shuffle };
|
||||
};
|
||||
|
|
@ -296,3 +296,18 @@ function eventStartedInThePast(
|
|||
databaseTimestampToDate(startTime).getTime() < new Date().getTime()
|
||||
);
|
||||
}
|
||||
|
||||
export function canEnableTOTools(user?: IsAdminUser) {
|
||||
return isAdmin(user);
|
||||
}
|
||||
|
||||
interface CanAdminCalendarTOTools {
|
||||
user?: Pick<User, "id" | "discordId">;
|
||||
event: Pick<CalendarEvent, "authorId">;
|
||||
}
|
||||
export function canAdminCalendarTOTools({
|
||||
user,
|
||||
event,
|
||||
}: CanAdminCalendarTOTools) {
|
||||
return adminOverride(user)(user?.id === event.authorId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export const loader: LoaderFunction = async ({ request }) => {
|
|||
};
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: "common",
|
||||
i18n: ["common", "game-misc"],
|
||||
};
|
||||
|
||||
function Document({
|
||||
|
|
@ -162,6 +162,7 @@ export const namespaceJsonsToPreloadObj: Record<
|
|||
gear: true,
|
||||
user: true,
|
||||
weapons: true,
|
||||
tournament: true,
|
||||
};
|
||||
const namespaceJsonsToPreload = Object.keys(namespaceJsonsToPreloadObj);
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import clsx from "clsx";
|
|||
import * as React from "react";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
import { z } from "zod";
|
||||
import type { AlertVariation } from "~/components/Alert";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { Badge } from "~/components/Badge";
|
||||
import { Button } from "~/components/Button";
|
||||
import { DateInput } from "~/components/DateInput";
|
||||
|
|
@ -20,14 +22,17 @@ import { TrashIcon } from "~/components/icons/Trash";
|
|||
import { Input } from "~/components/Input";
|
||||
import { Label } from "~/components/Label";
|
||||
import { Main } from "~/components/Main";
|
||||
import { MapPoolSelector } from "~/components/MapPoolSelector";
|
||||
import { RequiredHiddenInput } from "~/components/RequiredHiddenInput";
|
||||
import { Toggle } from "~/components/Toggle";
|
||||
import { CALENDAR_EVENT } from "~/constants";
|
||||
import { db } from "~/db";
|
||||
import type { Badge as BadgeType, CalendarEventTag } from "~/db/types";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { requireUser } from "~/modules/auth";
|
||||
import { requireUser, useUser } from "~/modules/auth";
|
||||
import { i18next } from "~/modules/i18n";
|
||||
import { MapPool } from "~/modules/map-pool-serializer";
|
||||
import { canEditCalendarEvent } from "~/permissions";
|
||||
import { canEditCalendarEvent, canEnableTOTools } from "~/permissions";
|
||||
import calendarNewStyles from "~/styles/calendar-new.css";
|
||||
import mapsStyles from "~/styles/maps.css";
|
||||
import {
|
||||
|
|
@ -46,6 +51,7 @@ import { makeTitle } from "~/utils/strings";
|
|||
import { calendarEventPage } from "~/utils/urls";
|
||||
import {
|
||||
actualNumber,
|
||||
checkboxValueToBoolean,
|
||||
date,
|
||||
falsyToNull,
|
||||
id,
|
||||
|
|
@ -54,7 +60,6 @@ import {
|
|||
safeJSONParse,
|
||||
toArray,
|
||||
} from "~/utils/zod";
|
||||
import { MapPoolSelector } from "~/components/MapPoolSelector";
|
||||
import { Tags } from "./components/Tags";
|
||||
import { isDefined } from "~/utils/arrays";
|
||||
import { CrossIcon } from "~/components/icons/Cross";
|
||||
|
|
@ -120,6 +125,7 @@ const newCalendarEventActionSchema = z.object({
|
|||
z.array(id).nullable()
|
||||
),
|
||||
pool: z.string().optional(),
|
||||
toToolsEnabled: z.preprocess(checkboxValueToBoolean, z.boolean()),
|
||||
});
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
|
|
@ -145,6 +151,7 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
.join(",")
|
||||
: data.tags,
|
||||
badges: data.badges ?? [],
|
||||
toToolsEnabled: canEnableTOTools(user) ? Number(data.toToolsEnabled) : 0,
|
||||
};
|
||||
|
||||
const deserializedMaps = (() => {
|
||||
|
|
@ -206,6 +213,8 @@ export const loader = async ({ request }: LoaderArgs) => {
|
|||
tags: eventToEdit.tags.filter((tag) => tag !== "BADGE"),
|
||||
badges: db.calendarEvents.findBadgesByEventId(eventId),
|
||||
mapPool: db.calendarEvents.findMapPoolByEventId(eventId),
|
||||
tieBreakerMapPool:
|
||||
db.calendarEvents.findTieBreakerMapPoolByEventId(eventId),
|
||||
}
|
||||
: undefined,
|
||||
title: makeTitle([canEditEvent ? "Edit" : "New", t("pages.calendar")]),
|
||||
|
|
@ -233,7 +242,7 @@ export default function CalendarNewEventPage() {
|
|||
<DiscordLinkInput />
|
||||
<TagsAdder />
|
||||
<BadgesAdder />
|
||||
<MapPoolSection />
|
||||
<TOToolsAndMapPool />
|
||||
<Button type="submit" className="mt-4">
|
||||
{t("actions.submit")}
|
||||
</Button>
|
||||
|
|
@ -577,6 +586,50 @@ function BadgesAdder() {
|
|||
);
|
||||
}
|
||||
|
||||
function TOToolsAndMapPool() {
|
||||
const user = useUser();
|
||||
const { eventToEdit } = useLoaderData<typeof loader>();
|
||||
const [checked, setChecked] = React.useState(
|
||||
Boolean(eventToEdit?.toToolsEnabled)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{canEnableTOTools(user) && (
|
||||
<TOToolsEnabler checked={checked} setChecked={setChecked} />
|
||||
)}
|
||||
{checked ? <CounterPickMapPoolSection /> : <MapPoolSection />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TOToolsEnabler({
|
||||
checked,
|
||||
setChecked,
|
||||
}: {
|
||||
checked: boolean;
|
||||
setChecked: (checked: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["calendar"]);
|
||||
const id = React.useId();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={id}>{t("calendar:forms.toTools.header")}</label>
|
||||
<Toggle
|
||||
name="toToolsEnabled"
|
||||
id={id}
|
||||
tiny
|
||||
checked={checked}
|
||||
setChecked={setChecked}
|
||||
/>
|
||||
<FormMessage type="info">
|
||||
{t("calendar:forms.toTools.explanation")}
|
||||
</FormMessage>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MapPoolSection() {
|
||||
const { t } = useTranslation(["game-misc", "common"]);
|
||||
|
||||
|
|
@ -610,3 +663,90 @@ function MapPoolSection() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CounterPickMapPoolSection() {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const { eventToEdit } = useLoaderData<typeof loader>();
|
||||
const [mapPool, setMapPool] = React.useState<MapPool>(
|
||||
eventToEdit?.tieBreakerMapPool
|
||||
? new MapPool(eventToEdit.tieBreakerMapPool)
|
||||
: MapPool.EMPTY
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<RequiredHiddenInput
|
||||
value={mapPool.serialized}
|
||||
name="pool"
|
||||
isValid={validateTiebreakerMapPool(mapPool) === "VALID"}
|
||||
/>
|
||||
|
||||
<MapPoolSelector
|
||||
className="w-full"
|
||||
mapPool={mapPool}
|
||||
handleMapPoolChange={setMapPool}
|
||||
title={t("common:maps.tieBreakerMapPool")}
|
||||
modesToInclude={["SZ", "TC", "RM", "CB"]}
|
||||
info={
|
||||
<div>
|
||||
<MapPoolValidationStatusMessage
|
||||
status={validateTiebreakerMapPool(mapPool)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type CounterPickValidationStatus =
|
||||
| "PICKING"
|
||||
| "VALID"
|
||||
| "NOT_ONE_MAP_PER_MODE"
|
||||
| "MAP_REPEATED"
|
||||
| "MODE_REPEATED";
|
||||
|
||||
function validateTiebreakerMapPool(
|
||||
mapPool: MapPool
|
||||
): CounterPickValidationStatus {
|
||||
if (mapPool.stages.length !== new Set(mapPool.stages).size) {
|
||||
return "MAP_REPEATED";
|
||||
}
|
||||
if (
|
||||
mapPool.parsed.SZ.length > 1 ||
|
||||
mapPool.parsed.TC.length > 1 ||
|
||||
mapPool.parsed.RM.length > 1 ||
|
||||
mapPool.parsed.CB.length > 1
|
||||
) {
|
||||
return "MODE_REPEATED";
|
||||
}
|
||||
if (
|
||||
mapPool.parsed.SZ.length < 1 ||
|
||||
mapPool.parsed.TC.length < 1 ||
|
||||
mapPool.parsed.RM.length < 1 ||
|
||||
mapPool.parsed.CB.length < 1
|
||||
) {
|
||||
return "PICKING";
|
||||
}
|
||||
|
||||
return "VALID";
|
||||
}
|
||||
|
||||
function MapPoolValidationStatusMessage({
|
||||
status,
|
||||
}: {
|
||||
status: CounterPickValidationStatus;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
const alertVariation: AlertVariation =
|
||||
status === "VALID" ? "SUCCESS" : status === "PICKING" ? "INFO" : "WARNING";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Alert alertClassName="w-max" variation={alertVariation} tiny>
|
||||
{t(`common:maps.validation.${status}`)}
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
100
app/routes/to.$identifier.tsx
Normal file
100
app/routes/to.$identifier.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import type {
|
||||
LinksFunction,
|
||||
LoaderArgs,
|
||||
MetaFunction,
|
||||
SerializeFrom,
|
||||
} from "@remix-run/node";
|
||||
import { Outlet, useLoaderData } from "@remix-run/react";
|
||||
import { SubNav, SubNavLink } from "~/components/SubNav";
|
||||
import { db } from "~/db";
|
||||
import type {
|
||||
FindTeamsByEventId,
|
||||
FindTeamsByEventIdItem,
|
||||
} from "~/db/models/tournaments/queries.server";
|
||||
import type { TournamentTeam } from "~/db/types";
|
||||
import { getUser, useUser } from "~/modules/auth";
|
||||
import { canAdminCalendarTOTools } from "~/permissions";
|
||||
import { notFoundIfFalsy, type SendouRouteHandle } from "~/utils/remix";
|
||||
import { findOwnedTeam } from "~/utils/tournaments";
|
||||
import styles from "~/styles/tournament.css";
|
||||
import { makeTitle } from "~/utils/strings";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
const data = args.data as SerializeFrom<typeof loader>;
|
||||
|
||||
if (!data) return {};
|
||||
|
||||
return {
|
||||
title: makeTitle(data.event.name),
|
||||
};
|
||||
};
|
||||
|
||||
export const links: LinksFunction = () => {
|
||||
return [{ rel: "stylesheet", href: styles }];
|
||||
};
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["tournament"],
|
||||
};
|
||||
|
||||
export type TournamentToolsLoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
export const loader = async ({ params, request }: LoaderArgs) => {
|
||||
const user = await getUser(request);
|
||||
const eventId = params["identifier"]!;
|
||||
|
||||
const event = notFoundIfFalsy(db.tournaments.findByIdentifier(eventId));
|
||||
const teams = db.tournaments.findTeamsByEventId(event.id);
|
||||
|
||||
return {
|
||||
event,
|
||||
tieBreakerMapPool:
|
||||
db.calendarEvents.findTieBreakerMapPoolByEventId(eventId),
|
||||
teams: event.isBeforeStart ? censorMapPools({ teams }) : teams,
|
||||
ownTeam: findOwnedTeam({ userId: user?.id, teams }),
|
||||
};
|
||||
};
|
||||
|
||||
function censorMapPools({
|
||||
teams,
|
||||
ownTeamId,
|
||||
}: {
|
||||
teams: FindTeamsByEventId;
|
||||
ownTeamId?: TournamentTeam["id"];
|
||||
}) {
|
||||
return teams.map((team) =>
|
||||
team.id === ownTeamId
|
||||
? team
|
||||
: {
|
||||
...team,
|
||||
mapPool:
|
||||
// can be used to show checkmark in UI if team has submitted
|
||||
// the map pool without revealing the contents
|
||||
team.mapPool.length > 0
|
||||
? ([] as FindTeamsByEventIdItem["mapPool"])
|
||||
: undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default function TournamentToolsLayout() {
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
const user = useUser();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SubNav>
|
||||
<SubNavLink to="">{t("tournament:tabs.info")}</SubNavLink>
|
||||
<SubNavLink to="teams">
|
||||
{t("tournament:tabs.teams", { count: data.teams.length })}
|
||||
</SubNavLink>
|
||||
{canAdminCalendarTOTools({ user, event: data.event }) && (
|
||||
<SubNavLink to="admin">{t("tournament:tabs.admin")}</SubNavLink>
|
||||
)}
|
||||
</SubNav>
|
||||
<Outlet context={data} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
76
app/routes/to.$identifier/admin.tsx
Normal file
76
app/routes/to.$identifier/admin.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { type ActionFunction } from "@remix-run/node";
|
||||
import { useOutletContext, useSubmit } from "@remix-run/react";
|
||||
import * as React from "react";
|
||||
import { z } from "zod";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Main } from "~/components/Main";
|
||||
import { Toggle } from "~/components/Toggle";
|
||||
import { db } from "~/db";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
import { requireUser } from "~/modules/auth";
|
||||
import { canAdminCalendarTOTools } from "~/permissions";
|
||||
import {
|
||||
badRequestIfFalsy,
|
||||
parseRequestFormData,
|
||||
validate,
|
||||
} from "~/utils/remix";
|
||||
import { checkboxValueToBoolean } from "~/utils/zod";
|
||||
import type { TournamentToolsLoaderData } from "../to.$identifier";
|
||||
|
||||
const tournamentToolsActionSchema = z.object({
|
||||
started: z.preprocess(checkboxValueToBoolean, z.boolean()),
|
||||
});
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const user = await requireUser(request);
|
||||
const data = await parseRequestFormData({
|
||||
request,
|
||||
schema: tournamentToolsActionSchema,
|
||||
});
|
||||
|
||||
const eventId = params["identifier"]!;
|
||||
const event = badRequestIfFalsy(db.tournaments.findByIdentifier(eventId));
|
||||
|
||||
validate(canAdminCalendarTOTools({ user, event }));
|
||||
|
||||
db.tournaments.updateIsBeforeStart({
|
||||
id: event.id,
|
||||
isBeforeStart: Number(!data.started),
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default function TournamentToolsAdminPage() {
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
const submit = useSubmit();
|
||||
const data = useOutletContext<TournamentToolsLoaderData>();
|
||||
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" });
|
||||
}
|
||||
|
||||
return (
|
||||
<Main halfWidth>
|
||||
<div>
|
||||
<label>{t("tournament:admin.eventStarted")}</label>
|
||||
<Toggle
|
||||
checked={eventStarted}
|
||||
setChecked={handleToggle}
|
||||
name="started"
|
||||
/>
|
||||
<FormMessage type="info">
|
||||
{t("tournament:admin.eventStarted.explanation")}
|
||||
</FormMessage>
|
||||
</div>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
47
app/routes/to.$identifier/components/TeamWithRoster.tsx
Normal file
47
app/routes/to.$identifier/components/TeamWithRoster.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import clsx from "clsx";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Button } from "~/components/Button";
|
||||
import { TrashIcon } from "~/components/icons/Trash";
|
||||
import type { FindTeamsByEventIdItem } from "~/db/models/tournaments/queries.server";
|
||||
import { useUser } from "~/modules/auth";
|
||||
|
||||
export function TeamWithRoster({
|
||||
team,
|
||||
showDeleteButtons = false,
|
||||
}: {
|
||||
team: Pick<FindTeamsByEventIdItem, "members" | "name">;
|
||||
showDeleteButtons?: boolean;
|
||||
}) {
|
||||
const user = useUser();
|
||||
|
||||
return (
|
||||
<div className="tournament__team-with-roster">
|
||||
<div className="tournament__team-with-roster__name">{team.name}</div>
|
||||
<ul className="tournament__team-with-roster__members">
|
||||
{team.members.map((member) => (
|
||||
<li
|
||||
key={member.userId}
|
||||
className="tournament__team-with-roster__member"
|
||||
>
|
||||
{showDeleteButtons && (
|
||||
<Button
|
||||
className={clsx({ invisible: user?.id === member.userId })}
|
||||
variant="minimal-destructive"
|
||||
tiny
|
||||
type="submit"
|
||||
name="id"
|
||||
value={member.userId}
|
||||
>
|
||||
<TrashIcon className="w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Avatar user={member} size="xxs" />
|
||||
<span className="tournament__team-member-name">
|
||||
{member.discordName}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
785
app/routes/to.$identifier/index.tsx
Normal file
785
app/routes/to.$identifier/index.tsx
Normal file
|
|
@ -0,0 +1,785 @@
|
|||
import type { ActionFunction, LinksFunction } from "@remix-run/node";
|
||||
import { Form, useActionData, useOutletContext } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
import { z } from "zod";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { Button } from "~/components/Button";
|
||||
import { Details, Summary } from "~/components/DetailsSummary";
|
||||
import { AlertIcon } from "~/components/icons/Alert";
|
||||
import { CheckmarkIcon } from "~/components/icons/Checkmark";
|
||||
import { Image } from "~/components/Image";
|
||||
import { Main } from "~/components/Main";
|
||||
import { MapPoolSelector } from "~/components/MapPoolSelector";
|
||||
import { RequiredHiddenInput } from "~/components/RequiredHiddenInput";
|
||||
import { TOURNAMENT } from "~/constants";
|
||||
import { db } from "~/db";
|
||||
import { requireUser } from "~/modules/auth";
|
||||
import type { StageId } from "~/modules/in-game-lists";
|
||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import { MapPool } from "~/modules/map-pool-serializer";
|
||||
import mapsStyles from "~/styles/maps.css";
|
||||
import {
|
||||
badRequestIfFalsy,
|
||||
parseRequestFormData,
|
||||
type SendouRouteHandle,
|
||||
validate,
|
||||
} from "~/utils/remix";
|
||||
import { findOwnedTeam } from "~/utils/tournaments";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { modeImageUrl } from "~/utils/urls";
|
||||
import type { TournamentToolsLoaderData } from "../to.$identifier";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator";
|
||||
import {
|
||||
createTournamentMapList,
|
||||
type BracketType,
|
||||
type TournamentMaplistInput,
|
||||
type TournamentMaplistSource,
|
||||
} from "~/modules/tournament-map-list-generator";
|
||||
import type { MapPoolMap } from "~/db/types";
|
||||
import invariant from "tiny-invariant";
|
||||
import { UserCombobox } from "~/components/Combobox";
|
||||
import { TeamWithRoster } from "./components/TeamWithRoster";
|
||||
import { actualNumber } from "~/utils/zod";
|
||||
import { useSearchParamState } from "~/hooks/useSearchParamState";
|
||||
|
||||
export const links: LinksFunction = () => {
|
||||
return [{ rel: "stylesheet", href: mapsStyles }];
|
||||
};
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["tournament"],
|
||||
};
|
||||
|
||||
const tournamentToolsActionSchema = z.union([
|
||||
z.object({
|
||||
_action: z.literal("TEAM_NAME"),
|
||||
name: z.string().min(1).max(TOURNAMENT.TEAM_NAME_MAX_LENGTH),
|
||||
}),
|
||||
z.object({
|
||||
_action: z.literal("POOL"),
|
||||
pool: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
_action: z.literal("DELETE_REGISTRATION"),
|
||||
}),
|
||||
z.object({
|
||||
_action: z.literal("ADD_MEMBER"),
|
||||
"user[value]": z.preprocess(actualNumber, z.number().positive()),
|
||||
}),
|
||||
z.object({
|
||||
_action: z.literal("DELETE_MEMBER"),
|
||||
id: z.preprocess(actualNumber, z.number().positive()),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const data = await parseRequestFormData({
|
||||
request,
|
||||
schema: tournamentToolsActionSchema,
|
||||
});
|
||||
const user = await requireUser(request);
|
||||
|
||||
const event = badRequestIfFalsy(
|
||||
db.tournaments.findByIdentifier(params["identifier"]!)
|
||||
);
|
||||
const teams = db.tournaments.findTeamsByEventId(event.id);
|
||||
const ownTeam = findOwnedTeam({ userId: user.id, teams });
|
||||
|
||||
if (!event.isBeforeStart) {
|
||||
return { failed: true };
|
||||
}
|
||||
|
||||
switch (data._action) {
|
||||
case "TEAM_NAME": {
|
||||
if (ownTeam) {
|
||||
db.tournaments.renameTeam({
|
||||
id: ownTeam.id,
|
||||
name: data.name,
|
||||
});
|
||||
} else {
|
||||
db.tournaments.addTeam({
|
||||
ownerId: user.id,
|
||||
name: data.name,
|
||||
calendarEventId: event.id,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "POOL": {
|
||||
validate(ownTeam);
|
||||
const mapPool = new MapPool(data.pool);
|
||||
validate(validateCounterPickMapPool(mapPool) === "VALID");
|
||||
|
||||
db.tournaments.upsertCounterpickMaps({
|
||||
mapPool,
|
||||
tournamentTeamId: ownTeam.id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "DELETE_REGISTRATION": {
|
||||
validate(ownTeam);
|
||||
db.tournaments.deleteTournamentTeam(ownTeam.id);
|
||||
break;
|
||||
}
|
||||
case "ADD_MEMBER": {
|
||||
validate(ownTeam);
|
||||
validate(ownTeam.members.length < TOURNAMENT.TEAM_MAX_MEMBERS);
|
||||
db.tournaments.addTeamMember({
|
||||
userId: data["user[value]"],
|
||||
tournamentTeamId: ownTeam.id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "DELETE_MEMBER": {
|
||||
validate(ownTeam);
|
||||
validate(data.id !== user.id);
|
||||
db.tournaments.deleteTeamMember({
|
||||
userId: data.id,
|
||||
tournamentTeamId: ownTeam.id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default function TournamentToolsPage() {
|
||||
const data = useOutletContext<TournamentToolsLoaderData>();
|
||||
|
||||
return (
|
||||
<Main>
|
||||
{data.event.isBeforeStart ? <PrestartControls /> : <MaplistGenerator />}
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function PrestartControls() {
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
const data = useOutletContext<TournamentToolsLoaderData>();
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
<TeamNameSection />
|
||||
{data.ownTeam && (
|
||||
<>
|
||||
<MapPoolSection />
|
||||
<RosterSection />
|
||||
<div className="tournament__action-side-note">
|
||||
{t("tournament:pre.footerNote")}
|
||||
</div>
|
||||
<FormWithConfirm
|
||||
fields={[["_action", "DELETE_REGISTRATION"]]}
|
||||
dialogHeading={`Delete data related to ${data.ownTeam.name}?`}
|
||||
>
|
||||
<Button
|
||||
tiny
|
||||
variant="minimal-destructive"
|
||||
type="submit"
|
||||
className="mt-4"
|
||||
>
|
||||
{t("tournament:pre.deleteTeam")}
|
||||
</Button>
|
||||
</FormWithConfirm>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamNameSection() {
|
||||
const { t } = useTranslation(["tournament", "common"]);
|
||||
const data = useOutletContext<TournamentToolsLoaderData>();
|
||||
|
||||
return (
|
||||
<section className="tournament__action-section stack md">
|
||||
<div>
|
||||
<span className="tournament__action-section-title">
|
||||
{t("tournament:pre.steps.register")}{" "}
|
||||
<a
|
||||
href={data.event.bracketUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{data.event.bracketUrl}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
<Details className="bg-darker-transparent rounded">
|
||||
<Summary className="bg-transparent-important">
|
||||
<div className="tournament__summary-content">
|
||||
{t("tournament:pre.steps.register.summary")}{" "}
|
||||
{data.ownTeam ? (
|
||||
<CheckmarkIcon className="fill-success" />
|
||||
) : (
|
||||
<AlertIcon className="fill-warning" />
|
||||
)}
|
||||
</div>
|
||||
</Summary>
|
||||
<Form method="post" className="mt-3 px-4 pb-4">
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
maxLength={TOURNAMENT.TEAM_NAME_MAX_LENGTH}
|
||||
defaultValue={data.ownTeam?.name}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
tiny
|
||||
className="mt-4"
|
||||
name="_action"
|
||||
value="TEAM_NAME"
|
||||
type="submit"
|
||||
>
|
||||
{t("common:actions.submit")}
|
||||
</Button>
|
||||
</Form>
|
||||
</Details>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MapPoolSection() {
|
||||
const { t } = useTranslation(["tournament", "common"]);
|
||||
const data = useOutletContext<TournamentToolsLoaderData>();
|
||||
const [counterpickMapPool, setCounterpickMapPool] = React.useState(
|
||||
data.ownTeam?.mapPool ? new MapPool(data.ownTeam.mapPool) : MapPool.EMPTY
|
||||
);
|
||||
|
||||
const hasPickedMapPool = (data.ownTeam?.mapPool.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Form method="post" className="tournament__action-section stack md">
|
||||
<div>
|
||||
<span className="tournament__action-section-title">
|
||||
{t("tournament:pre.steps.mapPool")}
|
||||
</span>
|
||||
<div className="tournament__action-side-note">
|
||||
{t("tournament:pre.steps.mapPool.explanation")}
|
||||
</div>
|
||||
</div>
|
||||
<Details className="bg-darker-transparent rounded">
|
||||
<Summary className="bg-transparent-important">
|
||||
<div className="tournament__summary-content">
|
||||
{t("tournament:pre.steps.mapPool.summary")}{" "}
|
||||
{hasPickedMapPool ? (
|
||||
<CheckmarkIcon className="fill-success" />
|
||||
) : (
|
||||
<AlertIcon className="fill-warning" />
|
||||
)}
|
||||
</div>
|
||||
</Summary>
|
||||
<RequiredHiddenInput
|
||||
value={counterpickMapPool.serialized}
|
||||
name="pool"
|
||||
isValid={validateCounterPickMapPool(counterpickMapPool) === "VALID"}
|
||||
/>
|
||||
<MapPoolSelector
|
||||
mapPool={counterpickMapPool}
|
||||
handleMapPoolChange={setCounterpickMapPool}
|
||||
className="bg-transparent-important"
|
||||
noTitle
|
||||
modesToInclude={["SZ", "TC", "RM", "CB"]}
|
||||
preselectedMapPool={new MapPool(data.tieBreakerMapPool)}
|
||||
info={
|
||||
<div className="stack md mt-2">
|
||||
<MapPoolCounts mapPool={counterpickMapPool} />
|
||||
<MapPoolValidationStatusMessage
|
||||
status={validateCounterPickMapPool(counterpickMapPool)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-4 w-max mx-auto"
|
||||
name="_action"
|
||||
value="POOL"
|
||||
tiny
|
||||
>
|
||||
{t("common:actions.saveChanges")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Details>
|
||||
</Form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type CounterPickValidationStatus =
|
||||
| "PICKING"
|
||||
| "VALID"
|
||||
| "TOO_MUCH_STAGE_REPEAT";
|
||||
|
||||
function validateCounterPickMapPool(
|
||||
mapPool: MapPool
|
||||
): 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) {
|
||||
return "TOO_MUCH_STAGE_REPEAT";
|
||||
}
|
||||
|
||||
stageCounts.set(stageId, stageCounts.get(stageId)! + 1);
|
||||
}
|
||||
|
||||
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
|
||||
) {
|
||||
return "PICKING";
|
||||
}
|
||||
|
||||
return "VALID";
|
||||
}
|
||||
|
||||
function MapPoolValidationStatusMessage({
|
||||
status,
|
||||
}: {
|
||||
status: CounterPickValidationStatus;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
||||
if (status !== "TOO_MUCH_STAGE_REPEAT") return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Alert alertClassName="w-max" variation="WARNING" tiny>
|
||||
{t(`common:maps.validation.${status}`, {
|
||||
maxStageRepeat: TOURNAMENT.COUNTERPICK_MAX_STAGE_REPEAT,
|
||||
})}
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MapPoolCounts({ mapPool }: { mapPool: MapPool }) {
|
||||
const { t } = useTranslation(["game-misc"]);
|
||||
|
||||
return (
|
||||
<div className="tournament__map-pool-counts">
|
||||
{rankedModesShort.map((mode) => (
|
||||
<Image
|
||||
key={mode}
|
||||
title={t(`game-misc:MODE_LONG_${mode}`)}
|
||||
alt={t(`game-misc:MODE_LONG_${mode}`)}
|
||||
path={modeImageUrl(mode)}
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
))}
|
||||
{rankedModesShort.map((mode) => {
|
||||
const currentLen = mapPool.parsed[mode].length;
|
||||
const targetLen = TOURNAMENT.COUNTERPICK_MAPS_PER_MODE;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={mode}
|
||||
className={clsx("tournament__map-pool-count", {
|
||||
"text-success": currentLen === targetLen,
|
||||
"text-error": currentLen > targetLen,
|
||||
})}
|
||||
>
|
||||
{currentLen}/{targetLen}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RosterSection() {
|
||||
const { t } = useTranslation(["tournament", "common"]);
|
||||
const data = useOutletContext<TournamentToolsLoaderData>();
|
||||
invariant(data.ownTeam);
|
||||
|
||||
const hasCompleteTeam =
|
||||
data.ownTeam.members.length >= TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL;
|
||||
const hasSpaceInTeam =
|
||||
data.ownTeam.members.length < TOURNAMENT.TEAM_MAX_MEMBERS;
|
||||
|
||||
return (
|
||||
<section className="tournament__action-section stack md">
|
||||
<div>
|
||||
<span className="tournament__action-section-title">
|
||||
{t("tournament:pre.steps.roster")}
|
||||
</span>
|
||||
<div className="tournament__action-side-note">
|
||||
{t("tournament:pre.steps.roster.explanation")}
|
||||
</div>
|
||||
</div>
|
||||
<Details className="bg-darker-transparent rounded">
|
||||
<Summary className="bg-transparent-important">
|
||||
<div className="tournament__summary-content">
|
||||
{t("tournament:pre.steps.roster.summary")}{" "}
|
||||
{hasCompleteTeam ? (
|
||||
<CheckmarkIcon className="fill-success" />
|
||||
) : (
|
||||
<AlertIcon className="fill-warning" />
|
||||
)}
|
||||
</div>
|
||||
</Summary>
|
||||
<div className="stack lg items-center px-2 py-4">
|
||||
{hasSpaceInTeam ? (
|
||||
<Form method="post" className="stack horizontal sm items-center">
|
||||
<UserCombobox
|
||||
inputName="user"
|
||||
required
|
||||
userIdsToOmit={
|
||||
new Set(data.ownTeam.members.map((m) => m.userId))
|
||||
}
|
||||
key={data.ownTeam.members.length}
|
||||
/>
|
||||
<Button tiny type="submit" name="_action" value="ADD_MEMBER">
|
||||
{t("common:actions.add")}
|
||||
</Button>
|
||||
</Form>
|
||||
) : (
|
||||
<div className="text-xs text-lighter">
|
||||
{t("tournament:pre.steps.roster.fullTeamError")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form method="post" className="w-full">
|
||||
<input type="hidden" name="_action" value="DELETE_MEMBER" />
|
||||
<TeamWithRoster team={data.ownTeam} showDeleteButtons />
|
||||
</Form>
|
||||
</div>
|
||||
</Details>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type TeamInState = {
|
||||
id: number;
|
||||
mapPool?: Pick<MapPoolMap, "mode" | "stageId">[];
|
||||
};
|
||||
function MaplistGenerator() {
|
||||
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: data.ownTeam?.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);
|
||||
const teamTwo = data.teams.find((t) => t.id === teamTwoId);
|
||||
invariant(teamOne);
|
||||
invariant(teamTwo);
|
||||
|
||||
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}
|
||||
/>
|
||||
</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: TeamInState;
|
||||
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));
|
||||
}}
|
||||
>
|
||||
{otherTeam.id !== -1 && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
40
app/routes/to.$identifier/teams.tsx
Normal file
40
app/routes/to.$identifier/teams.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { useOutletContext } from "@remix-run/react";
|
||||
import { AlertIcon } from "~/components/icons/Alert";
|
||||
import { CheckmarkIcon } from "~/components/icons/Checkmark";
|
||||
import { Image } from "~/components/Image";
|
||||
import { Main } from "~/components/Main";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
import { navIconUrl } from "~/utils/urls";
|
||||
import type { TournamentToolsLoaderData } from "../to.$identifier";
|
||||
import { TeamWithRoster } from "./components/TeamWithRoster";
|
||||
|
||||
export default function TournamentToolsTeamsPage() {
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
const data = useOutletContext<TournamentToolsLoaderData>();
|
||||
|
||||
return (
|
||||
<Main className="stack lg">
|
||||
{data.teams.map((team) => {
|
||||
return (
|
||||
<div key={team.id} className="stack sm items-center">
|
||||
<div className="tournament__pick-status-container">
|
||||
<Image
|
||||
path={navIconUrl("maps")}
|
||||
alt={t("tournament:teams.mapsPickedStatus")}
|
||||
title={t("tournament:teams.mapsPickedStatus")}
|
||||
height={16}
|
||||
width={16}
|
||||
/>
|
||||
{team.mapPool && team.mapPool.length > 0 ? (
|
||||
<CheckmarkIcon className="fill-success" />
|
||||
) : (
|
||||
<AlertIcon className="fill-warning" />
|
||||
)}
|
||||
</div>
|
||||
<TeamWithRoster team={team} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
|
@ -46,6 +46,7 @@ import {
|
|||
stackableAbility,
|
||||
toArray,
|
||||
} from "~/utils/zod";
|
||||
import { RequiredHiddenInput } from "~/components/RequiredHiddenInput";
|
||||
|
||||
const newBuildActionSchema = z.object({
|
||||
buildToEditId: z.preprocess(actualNumber, id.nullish()),
|
||||
|
|
@ -369,18 +370,10 @@ function Abilities() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
className="hidden-input-with-validation"
|
||||
<RequiredHiddenInput
|
||||
value={JSON.stringify(abilities)}
|
||||
isValid={abilities.flat().every((a) => a !== "UNKNOWN")}
|
||||
name="abilities"
|
||||
value={
|
||||
abilities.flat().every((a) => a !== "UNKNOWN")
|
||||
? JSON.stringify(abilities)
|
||||
: []
|
||||
}
|
||||
// empty onChange is because otherwise it will give a React error in console
|
||||
// readOnly can't be set as then validation is not active
|
||||
onChange={() => null}
|
||||
required
|
||||
/>
|
||||
<AbilitiesSelector
|
||||
selectedAbilities={abilities}
|
||||
|
|
|
|||
|
|
@ -218,6 +218,16 @@ label {
|
|||
|
||||
details summary {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.summary {
|
||||
border-radius: var(--rounded);
|
||||
background-color: var(--bg-darker-transparent);
|
||||
font-size: var(--fonts-xs);
|
||||
font-weight: var(--semi-bold);
|
||||
padding-block: var(--s-1);
|
||||
padding-inline: var(--s-2);
|
||||
}
|
||||
|
||||
fieldset {
|
||||
|
|
@ -230,7 +240,6 @@ fieldset {
|
|||
}
|
||||
|
||||
legend {
|
||||
border-radius: 2px;
|
||||
border-radius: var(--rounded-sm);
|
||||
background-color: transparent;
|
||||
font-size: var(--fonts-xs);
|
||||
|
|
@ -447,6 +456,7 @@ dialog::backdrop {
|
|||
justify-content: center;
|
||||
background-color: var(--bg-lighter);
|
||||
background-image: url("/svg/background-pattern.svg");
|
||||
margin-block-end: var(--s-4);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
|
|
@ -557,6 +567,44 @@ dialog::backdrop {
|
|||
padding-block: var(--s-2-5);
|
||||
}
|
||||
|
||||
.calendar__event__tags {
|
||||
display: flex;
|
||||
max-width: var(--tags-max-width, 18rem);
|
||||
flex-wrap: wrap;
|
||||
padding: 0;
|
||||
color: var(--black-text);
|
||||
font-size: var(--fonts-xxs);
|
||||
font-weight: var(--semi-bold);
|
||||
gap: var(--s-1);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.calendar__event__tags > li {
|
||||
display: flex;
|
||||
border-radius: var(--rounded);
|
||||
padding-inline: var(--s-1-5);
|
||||
}
|
||||
|
||||
.calendar__event__badge-tag {
|
||||
color: var(--badge-text);
|
||||
}
|
||||
|
||||
.calendar__event__tag-delete-button {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.calendar__event__tag-badges {
|
||||
display: flex;
|
||||
margin-inline-start: var(--s-1);
|
||||
}
|
||||
|
||||
.calendar__event__tag-delete-button > svg {
|
||||
width: 0.85rem !important;
|
||||
color: var(--black-text);
|
||||
margin-inline-end: 0 !important;
|
||||
margin-inline-start: var(--s-1);
|
||||
}
|
||||
|
||||
.alert {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
@ -575,6 +623,22 @@ dialog::backdrop {
|
|||
padding-inline-start: var(--s-3);
|
||||
}
|
||||
|
||||
.alert.tiny {
|
||||
font-size: var(--fonts-xs);
|
||||
}
|
||||
|
||||
.alert.warning {
|
||||
background-color: var(--theme-warning-transparent);
|
||||
}
|
||||
|
||||
.alert.error {
|
||||
background-color: var(--theme-error-transparent);
|
||||
}
|
||||
|
||||
.alert.success {
|
||||
background-color: var(--theme-success-transparent);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
border-radius: 50%;
|
||||
background-color: var(--bg-lighter);
|
||||
|
|
@ -586,6 +650,22 @@ dialog::backdrop {
|
|||
fill: var(--theme-info);
|
||||
}
|
||||
|
||||
.alert.tiny > svg {
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.alert.warning > svg {
|
||||
fill: var(--theme-warning);
|
||||
}
|
||||
|
||||
.alert.error > svg {
|
||||
fill: var(--theme-error);
|
||||
}
|
||||
|
||||
.alert.success > svg {
|
||||
fill: var(--theme-success);
|
||||
}
|
||||
|
||||
.form-errors {
|
||||
font-size: var(--fonts-sm);
|
||||
}
|
||||
|
|
@ -691,44 +771,6 @@ dialog::backdrop {
|
|||
margin-block-start: var(--label-margin);
|
||||
}
|
||||
|
||||
.calendar__event__tags {
|
||||
display: flex;
|
||||
max-width: var(--tags-max-width, 18rem);
|
||||
flex-wrap: wrap;
|
||||
padding: 0;
|
||||
color: var(--black-text);
|
||||
font-size: var(--fonts-xxs);
|
||||
font-weight: var(--semi-bold);
|
||||
gap: var(--s-1);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.calendar__event__tags > li {
|
||||
display: flex;
|
||||
border-radius: var(--rounded);
|
||||
padding-inline: var(--s-1-5);
|
||||
}
|
||||
|
||||
.calendar__event__badge-tag {
|
||||
color: var(--badge-text);
|
||||
}
|
||||
|
||||
.calendar__event__tag-delete-button {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.calendar__event__tag-badges {
|
||||
display: flex;
|
||||
margin-inline-start: var(--s-1);
|
||||
}
|
||||
|
||||
.calendar__event__tag-delete-button > svg {
|
||||
width: 0.85rem !important;
|
||||
color: var(--black-text);
|
||||
margin-inline-end: 0 !important;
|
||||
margin-inline-start: var(--s-1);
|
||||
}
|
||||
|
||||
.builds-container {
|
||||
display: grid;
|
||||
justify-content: center;
|
||||
|
|
|
|||
|
|
@ -73,11 +73,16 @@
|
|||
background-color: var(--bg-mode-active);
|
||||
}
|
||||
|
||||
.maps__mode-button.preselected {
|
||||
border: 2px solid var(--theme-info);
|
||||
background-color: var(--bg-mode-active);
|
||||
}
|
||||
|
||||
.maps__stage-image {
|
||||
border-radius: var(--rounded);
|
||||
}
|
||||
|
||||
.maps__mode:not(.selected) {
|
||||
.maps__mode:not(.selected, .preselected) {
|
||||
filter: var(--inactive-image-filter);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
|
|
|||
145
app/styles/tournament.css
Normal file
145
app/styles/tournament.css
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
.tournament__action-section {
|
||||
padding: var(--s-6);
|
||||
border-radius: var(--rounded);
|
||||
background-color: var(--bg-lighter);
|
||||
}
|
||||
|
||||
.tournament__action-section-title {
|
||||
font-size: var(--fonts-lg);
|
||||
font-weight: var(--bold);
|
||||
}
|
||||
|
||||
.tournament__action-side-note {
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-xs);
|
||||
font-weight: var(--semi-bold);
|
||||
}
|
||||
|
||||
.tournament__map-pool-counts {
|
||||
display: grid;
|
||||
width: 250px;
|
||||
margin: 0 auto;
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-xs);
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
place-items: center;
|
||||
row-gap: var(--s-2);
|
||||
}
|
||||
|
||||
.tournament__summary-content {
|
||||
display: inline-flex;
|
||||
gap: var(--s-3);
|
||||
}
|
||||
|
||||
.tournament__summary-content > svg {
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
.tournament__round-container {
|
||||
width: 250px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tournament__select-container > label {
|
||||
margin-left: var(--s-2-5);
|
||||
}
|
||||
|
||||
.tournament__teams-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
.tournament__team-select {
|
||||
width: 150px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tournament__bo-radios-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
.tournament__map-list {
|
||||
display: grid;
|
||||
justify-content: center;
|
||||
column-gap: var(--s-4);
|
||||
font-size: var(--fonts-sm);
|
||||
font-weight: var(--semi-bold);
|
||||
grid-template-columns: max-content max-content;
|
||||
}
|
||||
|
||||
.tournament__pick-info {
|
||||
align-self: center;
|
||||
font-size: var(--fonts-xxxs);
|
||||
}
|
||||
|
||||
.tournament__pick-info.team-1 {
|
||||
color: var(--theme-informative-blue);
|
||||
}
|
||||
|
||||
.tournament__pick-info.team-2 {
|
||||
color: var(--theme-informative-red);
|
||||
}
|
||||
|
||||
.tournament__pick-info.tiebreaker {
|
||||
color: var(--theme-informative-yellow);
|
||||
}
|
||||
|
||||
.tournament__pick-info.both {
|
||||
color: var(--theme-informative-green);
|
||||
}
|
||||
|
||||
.tournament__stage-listed {
|
||||
justify-self: flex-start;
|
||||
}
|
||||
|
||||
.tournament__team-with-roster {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tournament__team-with-roster__name {
|
||||
flex: 1;
|
||||
font-weight: var(--bold);
|
||||
padding-inline-end: var(--s-4);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.tournament__team-with-roster__members {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
border-inline-start: 2px solid var(--theme);
|
||||
font-size: var(--fonts-xs);
|
||||
font-weight: var(--semi-bold);
|
||||
gap: var(--s-2);
|
||||
list-style: none;
|
||||
padding-inline-start: var(--s-4);
|
||||
}
|
||||
|
||||
.tournament__team-with-roster__member {
|
||||
display: grid;
|
||||
gap: var(--s-1-5);
|
||||
grid-template-columns: max-content max-content 1fr;
|
||||
}
|
||||
|
||||
.tournament__team-member-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tournament__pick-status-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.tournament__pick-status-container > svg {
|
||||
width: 1rem;
|
||||
}
|
||||
|
|
@ -34,10 +34,34 @@
|
|||
color: var(--theme-success);
|
||||
}
|
||||
|
||||
.text-info {
|
||||
color: var(--theme-info);
|
||||
}
|
||||
|
||||
.fill-success {
|
||||
fill: var(--theme-success);
|
||||
}
|
||||
|
||||
.fill-warning {
|
||||
fill: var(--theme-warning);
|
||||
}
|
||||
|
||||
.bg-darker-important {
|
||||
background-color: var(--bg-darker) !important;
|
||||
}
|
||||
|
||||
.bg-transparent-important {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.bg-darker-transparent {
|
||||
background-color: var(--bg-darker-transparent);
|
||||
}
|
||||
|
||||
.rounded {
|
||||
border-radius: var(--rounded);
|
||||
}
|
||||
|
||||
.font-semi-bold {
|
||||
font-weight: var(--semi-bold);
|
||||
}
|
||||
|
|
@ -50,18 +74,46 @@
|
|||
width: 100% !important;
|
||||
}
|
||||
|
||||
.w-4 {
|
||||
width: var(--s-4);
|
||||
}
|
||||
|
||||
.w-24 {
|
||||
width: var(--s-24);
|
||||
}
|
||||
|
||||
.w-max {
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.px-4 {
|
||||
padding-inline: var(--s-4);
|
||||
}
|
||||
|
||||
.py-4 {
|
||||
padding-block: var(--s-4);
|
||||
}
|
||||
|
||||
.pl-4 {
|
||||
padding-inline-start: var(--s-4);
|
||||
}
|
||||
|
||||
.px-2 {
|
||||
padding-inline: var(--s-2);
|
||||
}
|
||||
|
||||
.pb-4 {
|
||||
padding-block-end: var(--s-4);
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-block-start: var(--s-2);
|
||||
}
|
||||
|
||||
.mt-3 {
|
||||
margin-block-start: var(--s-3);
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-block-start: var(--s-4);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,12 +18,17 @@ html {
|
|||
--text-lighter: rgb(75 75 75 / 95%);
|
||||
--divider: #635dab;
|
||||
--theme-error: rgb(199 13 6);
|
||||
--theme-error-transparent: rgba(199 13 6 / 75%);
|
||||
--theme-error-transparent: rgba(199 13 6 / 55%);
|
||||
--theme-warning: #c9c900;
|
||||
--theme-warning-transparent: #c9c90052;
|
||||
--theme-success: #00a514;
|
||||
--theme-success-transparent: #00a51452;
|
||||
--theme-info: #1fb0d0;
|
||||
--theme-info-transparent: #1fb0d052;
|
||||
--theme-informative-yellow: #b09901;
|
||||
--theme-informative-red: #9d0404;
|
||||
--theme-informative-blue: #007f9c;
|
||||
--theme-informative-green: #017a0f;
|
||||
--theme: hsl(255deg 64% 63%);
|
||||
--theme-vibrant: hsl(255deg 100% 81%);
|
||||
--theme-transparent: hsl(255deg 66.7% 75% / 40%);
|
||||
|
|
@ -96,12 +101,16 @@ html.dark {
|
|||
--black-text: rgb(0 0 0 / 95%);
|
||||
--text-lighter: rgb(215 214 255 / 80%);
|
||||
--theme-error: rgb(219 70 65);
|
||||
--theme-error-transparent: rgba(219 70 65 / 75%);
|
||||
--theme-error-transparent: rgba(219 70 65 / 55%);
|
||||
--theme-warning: #f5f587;
|
||||
--theme-success: #a3ffae;
|
||||
--theme-success-transparent: #a3ffae52;
|
||||
--theme-info: #87cddc;
|
||||
--theme-info-transparent: #87cddc52;
|
||||
--theme-informative-yellow: #ffed75;
|
||||
--theme-informative-red: #ff9494;
|
||||
--theme-informative-blue: #a7efff;
|
||||
--theme-informative-green: #a2ffad;
|
||||
--theme: hsl(255deg 66.7% 75%);
|
||||
--theme-vibrant: hsl(255deg 78% 65%);
|
||||
--theme-transparent: hsl(255deg 66.7% 75% / 40%);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ export function dateToDatabaseTimestamp(date: Date) {
|
|||
return Math.floor(date.getTime() / 1000);
|
||||
}
|
||||
|
||||
export function databaseCreatedAt() {
|
||||
return dateToDatabaseTimestamp(new Date());
|
||||
}
|
||||
|
||||
export function dateToWeekNumber(date: Date) {
|
||||
return getWeek(date, { weekStartsOn: 1, firstWeekContainsDate: 4 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,12 @@
|
|||
export function errorIsSqliteUniqueConstraintFailure(error: any) {
|
||||
return error?.code === "SQLITE_CONSTRAINT_UNIQUE";
|
||||
}
|
||||
|
||||
export function parseDBJsonArray(value: any) {
|
||||
const parsed = JSON.parse(value);
|
||||
|
||||
// If the returned array of JSON objects from DB is empty
|
||||
// it will be returned as object with all values being null
|
||||
// this is a workaround for that
|
||||
return parsed.filter((item: any) => Object.values(item).some(Boolean));
|
||||
}
|
||||
|
|
|
|||
16
app/utils/tournaments.ts
Normal file
16
app/utils/tournaments.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { FindTeamsByEventId } from "~/db/models/tournaments/queries.server";
|
||||
import type { User } from "~/db/types";
|
||||
|
||||
export function findOwnedTeam({
|
||||
teams,
|
||||
userId,
|
||||
}: {
|
||||
teams: FindTeamsByEventId;
|
||||
userId?: User["id"];
|
||||
}) {
|
||||
if (typeof userId !== "number") return;
|
||||
|
||||
return teams.find((team) =>
|
||||
team.members.some((member) => member.isOwner && member.userId === userId)
|
||||
);
|
||||
}
|
||||
|
|
@ -77,11 +77,14 @@ export const impersonateUrl = (idToLogInAs: number) =>
|
|||
export const badgePage = (badgeId: number) => `${BADGES_PAGE}/${badgeId}`;
|
||||
export const plusSuggestionPage = (tier?: string | number) =>
|
||||
`/plus/suggestions${tier ? `?tier=${tier}` : ""}`;
|
||||
|
||||
export const calendarEventPage = (eventId: number) => `/calendar/${eventId}`;
|
||||
export const calendarEditPage = (eventId?: number) =>
|
||||
`/calendar/new${eventId ? `?eventId=${eventId}` : ""}`;
|
||||
export const calendarReportWinnersPage = (eventId: number) =>
|
||||
`/calendar/${eventId}/report-winners`;
|
||||
export const toToolsPage = (eventId: number) => `/to/${eventId}`;
|
||||
|
||||
export const mapsPage = (eventId?: MapPoolMap["calendarEventId"]) =>
|
||||
`/maps${eventId ? `?eventId=${eventId}` : ""}`;
|
||||
export const readonlyMapsPage = (eventId: CalendarEvent["id"]) =>
|
||||
|
|
|
|||
77
migrations/012-to-tools.js
Normal file
77
migrations/012-to-tools.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
module.exports.up = function (db) {
|
||||
db.prepare(`alter table "CalendarEvent" add "customUrl" text`).run();
|
||||
db.prepare(
|
||||
`alter table "CalendarEvent" add "toToolsEnabled" integer default 0`
|
||||
).run();
|
||||
db.prepare(
|
||||
`alter table "CalendarEvent" add "isBeforeStart" integer default 1`
|
||||
).run();
|
||||
db.prepare(
|
||||
`create unique index calendar_event_custom_url_unique on "CalendarEvent"("customUrl")`
|
||||
).run();
|
||||
|
||||
// TODO: these should be FK's
|
||||
db.prepare(`alter table "MapPoolMap" add "tournamentTeamId" integer`).run();
|
||||
db.prepare(
|
||||
`alter table "MapPoolMap" add "tieBreakerCalendarEventId" integer`
|
||||
).run();
|
||||
db.prepare(
|
||||
`create index map_pool_map_tournament_team_id on "MapPoolMap"("tournamentTeamId")`
|
||||
).run();
|
||||
db.prepare(
|
||||
`create index map_pool_map_tie_breaker_calendar_event_id on "MapPoolMap"("tieBreakerCalendarEventId")`
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
create table "TournamentTeam" (
|
||||
"id" integer primary key,
|
||||
"name" text not null,
|
||||
"createdAt" integer not null,
|
||||
"seed" integer,
|
||||
"calendarEventId" integer not null,
|
||||
foreign key ("calendarEventId") references "CalendarEvent"("id") on delete cascade,
|
||||
unique("calendarEventId", "name") on conflict rollback
|
||||
) strict
|
||||
`
|
||||
).run();
|
||||
db.prepare(
|
||||
`create index tournament_team_calendar_event_id on "TournamentTeam"("calendarEventId")`
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
create table "TournamentTeamMember" (
|
||||
"tournamentTeamId" integer not null,
|
||||
"userId" integer not null,
|
||||
"isOwner" integer not null,
|
||||
"createdAt" integer not null,
|
||||
foreign key ("tournamentTeamId") references "TournamentTeam"("id") on delete cascade,
|
||||
unique("tournamentTeamId", "userId") on conflict rollback
|
||||
) strict
|
||||
`
|
||||
).run();
|
||||
db.prepare(
|
||||
`create index tournament_team_member_tournament_team_id on "TournamentTeamMember"("tournamentTeamId")`
|
||||
).run();
|
||||
};
|
||||
|
||||
module.exports.down = function (db) {
|
||||
db.prepare(`drop index calendar_event_custom_url_unique`).run();
|
||||
db.prepare(`drop index map_pool_map_tournament_team_id`).run();
|
||||
db.prepare(`drop index map_pool_map_tie_breaker_calendar_event_id`).run();
|
||||
|
||||
db.prepare(`alter table "CalendarEvent" drop column "customUrl"`).run();
|
||||
db.prepare(`alter table "CalendarEvent" drop column "toToolsEnabled"`).run();
|
||||
db.prepare(`alter table "CalendarEvent" drop column "isBeforeStart"`).run();
|
||||
db.prepare(`alter table "MapPoolMap" drop column "tournamentTeamId"`).run();
|
||||
db.prepare(
|
||||
`alter table "MapPoolMap" drop column "tieBreakerCalendarEventId"`
|
||||
).run();
|
||||
|
||||
db.prepare(`drop index tournament_team_calendar_event_id`).run();
|
||||
db.prepare(`drop index tournament_team_member_tournament_team_id`).run();
|
||||
|
||||
db.prepare(`drop table "TournamentTeam"`).run();
|
||||
db.prepare(`drop table "TournamentTeamMember"`).run();
|
||||
};
|
||||
|
|
@ -38,6 +38,9 @@
|
|||
"forms.errors.duplicatePlayer": "Can't have the same player twice in the same team.",
|
||||
"forms.errors.emptyTeam": "Each team must have at least one player.",
|
||||
|
||||
"forms.toTools.header": "Enable TO Tools",
|
||||
"forms.toTools.explanation": "With TO Tools your tournament will use prepicked maps and seed creator tool is available.",
|
||||
|
||||
"week.this": "This Week",
|
||||
"week.next": "Next Week",
|
||||
"week.last": "Last Week",
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
"footer.thanks": "Thanks to the patrons for the support",
|
||||
|
||||
"actions.save": "Save",
|
||||
"actions.saveChanges": "Save changes",
|
||||
"actions.saving": "Saving...",
|
||||
"actions.submit": "Submit",
|
||||
"actions.edit": "Edit",
|
||||
|
|
@ -49,6 +50,7 @@
|
|||
"maps.halfSz": "50% SZ",
|
||||
"maps.mapPool": "Map pool",
|
||||
"maps.tournamentMaplist": "Create tournament map list (maps.iplabs.ink)",
|
||||
"maps.tieBreakerMapPool": "Tie breaker map pool",
|
||||
"maps.template": "Template",
|
||||
"maps.template.none": "None",
|
||||
"maps.template.event": "Event",
|
||||
|
|
@ -57,6 +59,12 @@
|
|||
"maps.template.preset.ANARCHY": "Anarchy Modes",
|
||||
"maps.template.preset.ALL": "All Modes",
|
||||
"maps.template.preset.onlyMode": "Only {{modeName}}",
|
||||
"maps.validation.PICKING": "Pick one stage per mode",
|
||||
"maps.validation.NOT_ONE_MAP_PER_MODE": "Pick only one stage per mode",
|
||||
"maps.validation.MAP_REPEATED": "Can't pick same stage more than once",
|
||||
"maps.validation.MODE_REPEATED": "Can't pick same mode more than once",
|
||||
"maps.validation.TOO_MUCH_STAGE_REPEAT": "Can't pick stage more than {{maxStageRepeat}} times",
|
||||
"maps.validation.VALID": "Map pool ok!",
|
||||
|
||||
"results": "Results",
|
||||
|
||||
|
|
|
|||
43
public/locales/en/tournament.json
Normal file
43
public/locales/en/tournament.json
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"tabs.info": "Info",
|
||||
"tabs.teams": "Teams ({{count}})",
|
||||
"tabs.admin": "Admin",
|
||||
|
||||
"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",
|
||||
|
||||
"pre.steps.register": "1. Register on",
|
||||
"pre.steps.register.summary": "Enter team name you register 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",
|
||||
"pre.steps.roster": "3. Submit roster",
|
||||
"pre.steps.roster.explanation": "Submitting roster is optional but you might be seeded lower if you don't.",
|
||||
"pre.steps.roster.summary": "Enter roster",
|
||||
"pre.steps.roster.fullTeamError": "Team is full.",
|
||||
|
||||
"bracket.type.DE_WINNERS": "Winners Round",
|
||||
"bracket.type.DE_LOSERS": "Losers Round",
|
||||
"bracket.type.SE": "Round",
|
||||
"bracket.type.SWISS": "Swiss Round",
|
||||
"bracket.type.GROUPS": "Groups Round",
|
||||
|
||||
"round.label": "Round",
|
||||
|
||||
"team.label": "Team",
|
||||
"team.unlisted": "Unlisted team",
|
||||
|
||||
"bestOf.label.short": "Bo",
|
||||
|
||||
"pickInfo.team": "Team {{number}} pick",
|
||||
"pickInfo.tiebreaker": "Tiebreaker",
|
||||
"pickInfo.both": "Both picked",
|
||||
"pickInfo.default": "Default map",
|
||||
|
||||
"generator.error": "Changes you made weren't saved since tournament has started",
|
||||
|
||||
"teams.mapsPickedStatus": "Maps picked status",
|
||||
|
||||
"admin.eventStarted": "Event started",
|
||||
"admin.eventStarted.explanation": "After start teams can generate map lists but won't be able to edit their map pools or rosters."
|
||||
}
|
||||
|
|
@ -127,9 +127,6 @@ const translationProgressPath = path.join(
|
|||
|
||||
fs.writeFileSync(translationProgressPath, formattedMarkdown);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("translation-progress.md written");
|
||||
|
||||
function validateNoExtraKeysInOther({
|
||||
english,
|
||||
other,
|
||||
|
|
|
|||
2
types/react-i18next.d.ts
vendored
2
types/react-i18next.d.ts
vendored
|
|
@ -12,6 +12,7 @@ import type gear from "../public/locales/en/gear.json";
|
|||
import type builds from "../public/locales/en/builds.json";
|
||||
import type analyzer from "../public/locales/en/analyzer.json";
|
||||
import type gameMisc from "../public/locales/en/game-misc.json";
|
||||
import type tournament from "../public/locales/en/tournament.json";
|
||||
|
||||
declare module "react-i18next" {
|
||||
interface CustomTypeOptions {
|
||||
|
|
@ -29,6 +30,7 @@ declare module "react-i18next" {
|
|||
builds: typeof builds;
|
||||
analyzer: typeof analyzer;
|
||||
"game-misc": typeof gameMisc;
|
||||
tournament: typeof tournament;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user