* 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:
Kalle 2022-11-13 14:41:13 +02:00 committed by GitHub
parent f6990e93eb
commit ecd5a2a2f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 3188 additions and 150 deletions

View File

@ -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);
}
}

View 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>;
}

View File

@ -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"

View 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
/>
);
}

View File

@ -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>

View File

@ -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,

View File

@ -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,
};

View File

@ -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 *

View File

@ -0,0 +1,4 @@
insert into
"MapPoolMap" ("tieBreakerCalendarEventId", "stageId", "mode")
values
(@calendarEventId, @stageId, @mode)

View File

@ -2,3 +2,4 @@ delete from
"MapPoolMap"
where
"calendarEventId" = @calendarEventId
or "tieBreakerCalendarEventId" = @calendarEventId

View File

@ -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

View File

@ -0,0 +1,7 @@
select
"stageId",
"mode"
from
"MapPoolMap"
where
"tieBreakerCalendarEventId" = @calendarEventId

View File

@ -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">
>;
}

View File

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

View File

@ -0,0 +1,4 @@
insert into
"MapPoolMap" ("tournamentTeamId", "stageId", "mode")
values
(@tournamentTeamId, @stageId, @mode)

View File

@ -0,0 +1,4 @@
insert into
"TournamentTeam" ("name", "createdAt", "calendarEventId")
values
(@name, @createdAt, @calendarEventId) returning *;

View File

@ -0,0 +1,9 @@
insert into
"TournamentTeamMember" (
"tournamentTeamId",
"userId",
"isOwner",
"createdAt"
)
values
(@tournamentTeamId, @userId, @isOwner, @createdAt);

View File

@ -0,0 +1,4 @@
delete from
"MapPoolMap"
where
"tournamentTeamId" = @tournamentTeamId

View File

@ -0,0 +1,5 @@
delete from
"TournamentTeamMember"
where
"userId" = @userId
and "tournamentTeamId" = @tournamentTeamId;

View File

@ -0,0 +1,4 @@
delete from
"TournamentTeam"
where
"id" = @id

View 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

View 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

View 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 });
}

View File

@ -0,0 +1,6 @@
update
"TournamentTeam"
set
"name" = @name
where
"id" = @id;

View File

@ -0,0 +1,6 @@
update
"CalendarEvent"
set
"isBeforeStart" = @isBeforeStart
where
"id" = @id;

View File

@ -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]);

View File

@ -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;
}

View 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;
}
}

View File

@ -7,3 +7,4 @@ export const modes = [
] as const;
export const modesShort = modes.map((mode) => mode.short);
export const rankedModesShort = modesShort.slice(1);

View File

@ -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;
}

View 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 },
]);

View 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();

View File

@ -0,0 +1,7 @@
export { createTournamentMapList } from "./tournament-map-list";
export type {
BracketType,
TournamentMaplistInput,
TournamentMaplistSource,
TournamentMapListMap,
} from "./types";

View 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;
}
}

View 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;
};

View 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 };
};

View File

@ -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);
}

View File

@ -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);

View File

@ -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>
);
}

View 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} />
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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}

View File

@ -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;

View File

@ -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
View 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;
}

View File

@ -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);
}

View File

@ -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%);

View File

@ -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 });
}

View File

@ -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
View 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)
);
}

View File

@ -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"]) =>

View 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();
};

View File

@ -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",

View File

@ -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",

View 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."
}

View File

@ -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,

View File

@ -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;
};
}
}