Tournament full (#1373)

* Got something going

* Style overwrites

* width != height

* More playing with lines

* Migrations

* Start bracket initial

* Unhardcode stage generation params

* Link to match page

* Matches page initial

* Support directly adding seed to map list generator

* Add docs

* Maps in matches page

* Add invariant about tie breaker map pool

* Fix PICNIC lacking tie breaker maps

* Only link in bracket when tournament has started

* Styled tournament roster inputs

* Prefer IGN in tournament match page

* ModeProgressIndicator

* Some conditional rendering

* Match action initial + better error display

* Persist bestOf in DB

* Resolve best of ahead of time

* Move brackets-manager to core

* Score reporting works

* Clear winner on score report

* ModeProgressIndicator: highlight winners

* Fix inconsistent input

* Better text when submitting match

* mapCountPlayedInSetWithCertainty that works

* UNDO_REPORT_SCORE implemented

* Permission check when starting tournament

* Remove IGN from upsert

* View match results page

* Source in DB

* Match page waiting for teams

* Move tournament bracket to feature folder

* REOPEN_MATCH initial

* Handle proper resetting of match

* Inline bracket-manager

* Syncify

* Transactions

* Handle match is locked gracefully

* Match page auto refresh

* Fix match refresh called "globally"

* Bracket autoupdate

* Move fillWithNullTillPowerOfTwo to utils with testing

* Fix map lists not visible after tournament started

* Optimize match events

* Show UI while in progress to members

* Fix start tournament alert not being responsive

* Teams can check in

* Fix map list 400

* xxx -> TODO

* Seeds page

* Remove map icons for team page

* Don't display link to seeds after tournament has started

* Admin actions initial

* Change captain admin action

* Make all hooks ts

* Admin actions functioning

* Fix validate error not displaying in CatchBoundary

* Adjust validate args order

* Remove admin loader

* Make delete team button menancing

* Only include checked in teams to bracket

* Optimize to.id route loads

* Working show map list generator toggle

* Update full tournaments flow

* Make full tournaments work with many start times

* Handle undefined in crud

* Dynamic stage banner

* Handle default strat if map list generation fails

* Fix crash on brackets if less than 2 teams

* Add commented out test for reference

* Add TODO

* Add players from team during register

* TrustRelationship

* Prefers not to host feature

* Last before merge

* Rename some vars

* More renames
This commit is contained in:
Kalle 2023-05-15 22:37:43 +03:00 committed by GitHub
parent ab5c6cf7bb
commit ef78d3a2c2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
142 changed files with 12284 additions and 633 deletions

View File

@ -62,10 +62,14 @@ export function Catcher() {
return (
<Main>
<h2>Error {caught.status}</h2>
{caught.data ? (
<code>{JSON.stringify(caught.data, null, 2)}</code>
) : null}
<GetHelp />
<div className="text-sm text-lighter font-semi-bold">
Please include the message below if any and an explanation on what
you were doing:
</div>
{caught.data ? (
<pre>{JSON.stringify(JSON.parse(caught.data), null, 2)}</pre>
) : null}
</Main>
);
}

View File

@ -0,0 +1,3 @@
export function Divider({ children }: { children: React.ReactNode }) {
return <div className="divider">{children}</div>;
}

View File

@ -0,0 +1,35 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type * as React from "react";
export function Draggable({
id,
disabled,
liClassName,
children,
}: {
id: number;
disabled: boolean;
liClassName: string;
children: React.ReactNode;
}) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id, disabled });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<li
className={liClassName}
style={style}
ref={setNodeRef}
{...listeners}
{...attributes}
>
{children}
</li>
);
}

View File

@ -12,6 +12,7 @@ interface ImageProps {
height?: number;
style?: React.CSSProperties;
testId?: string;
onClick?: () => void;
}
export function Image({
@ -24,9 +25,10 @@ export function Image({
style,
testId,
containerClassName,
onClick,
}: ImageProps) {
return (
<picture title={title} className={containerClassName}>
<picture title={title} className={containerClassName} onClick={onClick}>
<source
type="image/avif"
srcSet={`${path}.avif`}

View File

@ -1,10 +1,17 @@
export function CheckmarkIcon({ className }: { className?: string }) {
export function CheckmarkIcon({
className,
testId,
}: {
className?: string;
testId?: string;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className={className}
viewBox="0 0 20 20"
fill="currentColor"
data-testid={testId}
>
<path
fillRule="evenodd"

View File

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

View File

@ -0,0 +1,4 @@
insert into
"Tournament" ("mapPickingStyle", "format")
values
(@mapPickingStyle, @format) returning *

View File

@ -15,4 +15,5 @@ where
"CalendarEvent"."authorId" = @authorId
and "startTime" > @lowerLimitTime
and "startTime" < @upperLimitTime
and "CalendarEvent"."participantCount" is null
and "CalendarEvent"."participantCount" is null
and "CalendarEvent"."tournamentId" is null

View File

@ -3,6 +3,7 @@ select
"CalendarEvent"."discordUrl",
"CalendarEvent"."bracketUrl",
"CalendarEvent"."tags",
"CalendarEvent"."tournamentId",
"CalendarEventDate"."id" as "eventDateId",
"CalendarEventDate"."eventId",
"CalendarEventDate"."startTime",
@ -38,4 +39,4 @@ where
"CalendarEventDate"."startTime" between @startTime
and @endTime
order by
"CalendarEventDate"."startTime" asc
"CalendarEventDate"."startTime" asc

View File

@ -5,9 +5,9 @@ select
"CalendarEvent"."discordUrl",
"CalendarEvent"."bracketUrl",
"CalendarEvent"."tags",
"CalendarEvent"."tournamentId",
"CalendarEvent"."participantCount",
"CalendarEvent"."toToolsEnabled",
"CalendarEvent"."toToolsMode",
"Tournament"."mapPickingStyle",
"User"."id" as "authorId",
exists (
select
@ -27,6 +27,7 @@ from
"CalendarEvent"
join "CalendarEventDate" on "CalendarEvent"."id" = "CalendarEventDate"."eventId"
join "User" on "CalendarEvent"."authorId" = "User"."id"
left join "Tournament" on "CalendarEvent"."tournamentId" = "Tournament"."id"
where
"CalendarEvent"."id" = @id
order by

View File

@ -10,6 +10,7 @@ import type {
CalendarEventResultTeam,
CalendarEventResultPlayer,
MapPoolMap,
Tournament,
} from "../../types";
import { MapPool } from "~/modules/map-pool-serializer";
@ -39,6 +40,7 @@ import findRecentMapPoolsByAuthorIdSql from "./findRecentMapPoolsByAuthorId.sql"
import findAllEventsWithMapPoolsSql from "./findAllEventsWithMapPools.sql";
import findTieBreakerMapPoolByEventIdSql from "./findTieBreakerMapPoolByEventId.sql";
import deleteByIdSql from "./deleteById.sql";
import createTournamentSql from "./createTournament.sql";
const createStm = sql.prepare(createSql);
const updateStm = sql.prepare(updateSql);
@ -56,6 +58,13 @@ const findTieBreakerMapPoolByEventIdtm = sql.prepare(
findTieBreakerMapPoolByEventIdSql
);
const deleteByIdStm = sql.prepare(deleteByIdSql);
const createTournamentStm = sql.prepare(createTournamentSql);
const createTournament = (
args: Omit<Tournament, "id" | "showMapListGenerator">
) => {
return createTournamentStm.get(args) as Tournament;
};
export type CreateArgs = Pick<
CalendarEvent,
@ -65,12 +74,12 @@ export type CreateArgs = Pick<
| "description"
| "discordInviteCode"
| "bracketUrl"
| "toToolsEnabled"
| "toToolsMode"
> & {
startTimes: Array<CalendarEventDate["startTime"]>;
badges: Array<CalendarEventBadge["badgeId"]>;
mapPoolMaps?: Array<Pick<MapPoolMap, "mode" | "stageId">>;
createTournament: boolean;
mapPickingStyle: Tournament["mapPickingStyle"];
};
export const create = sql.transaction(
({
@ -79,7 +88,18 @@ export const create = sql.transaction(
mapPoolMaps = [],
...calendarEventArgs
}: CreateArgs) => {
const createdEvent = createStm.get(calendarEventArgs) as CalendarEvent;
let tournamentId;
if (calendarEventArgs.createTournament) {
tournamentId = createTournament({
// TODO: format picking
format: "DE",
mapPickingStyle: calendarEventArgs.mapPickingStyle,
}).id;
}
const createdEvent = createStm.get({
...calendarEventArgs,
tournamentId,
}) as CalendarEvent;
for (const startTime of startTimes) {
createDateStm.run({
@ -98,25 +118,31 @@ export const create = sql.transaction(
upsertMapPool({
eventId: createdEvent.id,
mapPoolMaps,
toToolsEnabled: calendarEventArgs.toToolsEnabled,
isFullTournament: calendarEventArgs.createTournament,
});
return createdEvent.id;
}
);
export type Update = Omit<CreateArgs, "authorId"> & {
export type Update = Omit<
CreateArgs,
"authorId" | "createTournament" | "mapPickingStyle"
> & {
eventId: CalendarEvent["id"];
};
export const update = sql.transaction(
({
startTimes,
badges,
eventId,
mapPoolMaps = [],
eventId,
...calendarEventArgs
}: Update) => {
updateStm.run({ ...calendarEventArgs, eventId });
const event = updateStm.get({
...calendarEventArgs,
eventId,
}) as CalendarEvent;
deleteDatesByEventIdStm.run({ eventId });
for (const startTime of startTimes) {
@ -134,25 +160,28 @@ export const update = sql.transaction(
});
}
upsertMapPool({
eventId,
mapPoolMaps,
toToolsEnabled: calendarEventArgs.toToolsEnabled,
});
// can't edit tournament specific info after creation
if (!event.tournamentId) {
upsertMapPool({
eventId,
mapPoolMaps,
isFullTournament: false,
});
}
}
);
function upsertMapPool({
eventId,
mapPoolMaps,
toToolsEnabled,
isFullTournament,
}: {
eventId: Update["eventId"];
mapPoolMaps: NonNullable<Update["mapPoolMaps"]>;
toToolsEnabled: Update["toToolsEnabled"];
isFullTournament: boolean;
}) {
deleteMapPoolMapsStm.run({ calendarEventId: eventId });
if (toToolsEnabled) {
if (isFullTournament) {
for (const mapPoolArgs of mapPoolMaps) {
createTieBreakerMapPoolMapStm.run({
calendarEventId: eventId,
@ -309,12 +338,17 @@ const findAllBetweenTwoTimestampsStm = sql.prepare(
);
function addTagArray<
T extends { hasBadge: number; tags?: CalendarEvent["tags"] }
T extends {
hasBadge: number;
tags?: CalendarEvent["tags"];
tournamentId: CalendarEvent["tournamentId"];
}
>(arg: T) {
const { hasBadge, ...row } = arg;
const tags = (row.tags ? row.tags.split(",") : []) as Array<CalendarEventTag>;
if (hasBadge) tags.unshift("BADGE");
if (row.tournamentId) tags.unshift("FULL_TOURNAMENT");
return { ...row, tags };
}
@ -330,7 +364,10 @@ export function findAllBetweenTwoTimestamps({
startTime: dateToDatabaseTimestamp(startTime),
endTime: dateToDatabaseTimestamp(endTime),
}) as Array<
Pick<CalendarEvent, "name" | "discordUrl" | "bracketUrl" | "tags"> &
Pick<
CalendarEvent,
"name" | "discordUrl" | "bracketUrl" | "tags" | "tournamentId"
> &
Pick<CalendarEventDate, "eventId" | "startTime"> & {
eventDateId: CalendarEventDate["id"];
} & Pick<User, "discordName" | "discordDiscriminator"> & {
@ -354,9 +391,9 @@ export function findById(id: CalendarEvent["id"]) {
| "tags"
| "authorId"
| "participantCount"
| "toToolsEnabled"
| "toToolsMode"
| "tournamentId"
> &
Pick<Tournament, "mapPickingStyle"> &
Pick<CalendarEventDate, "startTime" | "eventId"> &
Pick<
User,

View File

@ -5,8 +5,6 @@ set
"tags" = @tags,
"description" = @description,
"discordInviteCode" = @discordInviteCode,
"bracketUrl" = @bracketUrl,
"toToolsEnabled" = @toToolsEnabled,
"toToolsMode" = @toToolsMode
"bracketUrl" = @bracketUrl
where
"id" = @eventId
"id" = @eventId returning *;

View File

@ -37,12 +37,15 @@ import {
NZAP_TEST_ID,
AMOUNT_OF_CALENDAR_EVENTS,
} from "./constants";
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
import type { SeedVariation } from "~/routes/seed";
const calendarEventWithToToolsSz = () => calendarEventWithToTools(true);
const calendarEventWithToToolsTeamsSz = () =>
calendarEventWithToToolsTeams(true);
const basicSeeds = [
const basicSeeds = (variation?: SeedVariation | null) => [
adminUser,
makeAdminPatron,
makeAdminVideoAdder,
@ -63,9 +66,13 @@ const basicSeeds = [
calendarEventResults,
calendarEventWithToTools,
calendarEventWithToToolsTieBreakerMapPool,
calendarEventWithToToolsTeams,
calendarEventWithToToolsSz,
calendarEventWithToToolsTeamsSz,
variation === "NO_TOURNAMENT_TEAMS"
? undefined
: calendarEventWithToToolsTeams,
variation === "NO_TOURNAMENT_TEAMS" ? undefined : calendarEventWithToToolsSz,
variation === "NO_TOURNAMENT_TEAMS"
? undefined
: calendarEventWithToToolsTeamsSz,
adminBuilds,
manySplattershotBuilds,
detailedTeam,
@ -76,10 +83,11 @@ const basicSeeds = [
userFavBadges,
];
export function seed() {
export function seed(variation?: SeedVariation | null) {
wipeDB();
for (const seedFunc of basicSeeds) {
for (const seedFunc of basicSeeds(variation)) {
if (!seedFunc) continue;
seedFunc();
}
}
@ -92,7 +100,9 @@ function wipeDB() {
"Build",
"TournamentTeamMember",
"MapPoolMap",
"TournamentMatchGameResult",
"TournamentTeam",
"Tournament",
"CalendarEventDate",
"CalendarEventResultPlayer",
"CalendarEventResultTeam",
@ -445,6 +455,13 @@ function userIdsInRandomOrder(specialLast = false) {
return [...rows.filter((id) => id !== 1 && id !== 2), 1, 2];
}
function userIdsInAscendingOrderById() {
return sql
.prepare(`select "id" from "User" order by id asc`)
.all()
.map((u) => u.id) as number[];
}
function calendarEvents() {
const userIds = userIdsInRandomOrder();
@ -610,8 +627,29 @@ function calendarEventResults() {
const TO_TOOLS_CALENDAR_EVENT_ID = 201;
function calendarEventWithToTools(sz?: boolean) {
const tournamentId = sz ? 2 : 1;
const eventId = TO_TOOLS_CALENDAR_EVENT_ID + (sz ? 1 : 0);
sql
.prepare(
`
insert into "Tournament" (
"id",
"mapPickingStyle",
"format"
) values (
$id,
$mapPickingStyle,
$format
) returning *
`
)
.run({
id: tournamentId,
format: "DE",
mapPickingStyle: sz ? "AUTO_SZ" : "AUTO_ALL",
});
sql
.prepare(
`
@ -622,8 +660,7 @@ function calendarEventWithToTools(sz?: boolean) {
"discordInviteCode",
"bracketUrl",
"authorId",
"toToolsEnabled",
"toToolsMode"
"tournamentId"
) values (
$id,
$name,
@ -631,8 +668,7 @@ function calendarEventWithToTools(sz?: boolean) {
$discordInviteCode,
$bracketUrl,
$authorId,
$toToolsEnabled,
$toToolsMode
$tournamentId
)
`
)
@ -643,8 +679,7 @@ function calendarEventWithToTools(sz?: boolean) {
discordInviteCode: faker.lorem.word(),
bracketUrl: faker.internet.url(),
authorId: 1,
toToolsEnabled: 1,
toToolsMode: sz ? "SZ" : null,
tournamentId,
});
sql
@ -661,7 +696,7 @@ function calendarEventWithToTools(sz?: boolean) {
)
.run({
eventId,
startTime: dateToDatabaseTimestamp(new Date()),
startTime: dateToDatabaseTimestamp(new Date(Date.now() + 1000 * 60 * 60)),
});
}
@ -695,9 +730,16 @@ function calendarEventWithToToolsTieBreakerMapPool() {
}
}
const validTournamentTeamName = () => {
while (true) {
const name = faker.music.songName();
if (name.length <= TOURNAMENT.TEAM_NAME_MAX_LENGTH) return name;
}
};
const names = Array.from(
new Set(new Array(100).fill(null).map(() => faker.music.songName()))
);
new Set(new Array(100).fill(null).map(() => validTournamentTeamName()))
).concat("Chimera");
const availableStages: StageId[] = [1, 2, 3, 4, 6, 7, 8, 10, 11];
const availablePairs = rankedModesShort
.flatMap((mode) =>
@ -705,8 +747,8 @@ const availablePairs = rankedModesShort
)
.filter((pair) => !tiebreakerPicks.has(pair));
function calendarEventWithToToolsTeams(sz?: boolean) {
const userIds = userIdsInRandomOrder(true);
for (let id = 1; id <= 40; id++) {
const userIds = userIdsInAscendingOrderById();
for (let id = 1; id <= 14; id++) {
sql
.prepare(
`
@ -714,13 +756,13 @@ function calendarEventWithToToolsTeams(sz?: boolean) {
"id",
"name",
"createdAt",
"calendarEventId",
"tournamentId",
"inviteCode"
) values (
$id,
$name,
$createdAt,
$calendarEventId,
$tournamentId,
$inviteCode
)
`
@ -729,13 +771,32 @@ function calendarEventWithToToolsTeams(sz?: boolean) {
id: id + (sz ? 100 : 0),
name: names.pop(),
createdAt: dateToDatabaseTimestamp(new Date()),
calendarEventId: TO_TOOLS_CALENDAR_EVENT_ID + (sz ? 1 : 0),
tournamentId: sz ? 2 : 1,
inviteCode: nanoid(INVITE_CODE_LENGTH),
});
if (id !== 1) {
sql
.prepare(
`
insert into "TournamentTeamCheckIn" (
"tournamentTeamId",
"checkedInAt"
) values (
$tournamentTeamId,
$checkedInAt
)
`
)
.run({
tournamentTeamId: id + (sz ? 100 : 0),
checkedInAt: dateToDatabaseTimestamp(new Date()),
});
}
for (
let i = 0;
i < faker.helpers.arrayElement([1, 2, 3, 4, 4, 4, 4, 4, 4, 5, 6, 7, 8]);
i < faker.helpers.arrayElement([4, 4, 4, 4, 4, 5, 5, 6]);
i++
) {
sql
@ -756,7 +817,7 @@ function calendarEventWithToToolsTeams(sz?: boolean) {
)
.run({
tournamentTeamId: id + (sz ? 100 : 0),
userId: userIds.pop()!,
userId: userIds.shift()!,
isOwner: i === 0 ? 1 : 0,
createdAt: dateToDatabaseTimestamp(new Date()),
});

View File

@ -114,11 +114,7 @@ export interface CalendarEvent {
discordUrl: string | null;
bracketUrl: string;
participantCount: number | null;
customUrl: string | null;
/** Is tournament tools page visible */
toToolsEnabled: number;
toToolsMode: RankedModeShort | null;
isBeforeStart: number;
tournamentId: number | null;
}
export type CalendarEventTag = keyof typeof allTags;
@ -184,15 +180,39 @@ export interface MapPoolMap {
mode: ModeShort;
}
// AUTO = style where teams pick their map pool ahead of time and the map lists are automatically made for each round
// could also have the traditional style where TO picks the maps later
type TournamentMapPickingStyle =
| "AUTO_ALL"
| "AUTO_SZ"
| "AUTO_TC"
| "AUTO_RM"
| "AUTO_CB";
// TODO: later also e.g. RR_TO_DE where we also need an additional field
// describing how many teams advance
export type TournamentFormat = "SE" | "DE";
export interface Tournament {
id: number;
mapPickingStyle: TournamentMapPickingStyle;
format: TournamentFormat;
showMapListGenerator: number;
}
export interface TournamentTeam {
id: number;
// TODO: make non-nullable in database as well
name: string;
createdAt: number;
seed: number | null;
calendarEventId: number;
tournamentId: number;
inviteCode: string;
checkedInAt?: number;
prefersNotToHost: number;
}
export interface TournamentTeamCheckIn {
tournamentTeamId: number;
checkedInAt: number;
}
export interface TournamentTeamMember {
@ -202,50 +222,106 @@ export interface TournamentTeamMember {
createdAt: number;
}
export type BracketType = "SE" | "DE";
export interface TournamentBracket {
/** A stage is an intermediate phase in a tournament.
* Supported stage types are round-robin, single elimination and double elimination. */
export interface TournamentStage {
id: number;
calendarEventId: number;
type: BracketType;
tournamentId: number;
name: string;
type: "round_robin" | "single_elimination" | "double_elimination";
settings: string; // json
number: number;
}
/** A group is a logical structure used to group multiple rounds together.
- In round-robin stages, a group is a pool.
- In elimination stages, a group is a bracket.
- A single elimination stage can have one or two groups:
- The unique bracket.
- If enabled, the Consolation Final.
- A double elimination stage can have two or three groups:
- Upper and lower brackets.
- If enabled, the Grand Final. */
export interface TournamentGroup {
id: number;
stageId: number;
/** In double elimination 1 = Winners, 2 = Losers, 3 = Grand Finals+Bracket Reset */
number: number;
}
/**
* A round is a logical structure used to group multiple matches together.
- In round-robin stages, a round can be viewed as a day or just as a list of matches that can be played at the same time.
- In elimination stages, a round is a round of a bracket, e.g. 8th finals, semi-finals, etc.
*/
export interface TournamentRound {
id: number;
// position of the round 1 for Round 1, 2 for Round 2, -1 for Losers Round 1 etc.
position: number;
bracketId: number;
bestOf: number;
stageId: number;
groupId: number;
number: number;
}
export enum Status {
/** The two matches leading to this one are not completed yet. */
Locked = 0,
/** One participant is ready and waiting for the other one. */
Waiting = 1,
/** Both participants are ready to start. */
Ready = 2,
/** The match is running. */
Running = 3,
/** The match is completed. */
Completed = 4,
/** At least one participant started their following match. */
Archived = 5,
}
/** A match between two participants (more participants are not allowed).
* Participants can be teams or individuals. */
export interface TournamentMatch {
id: number;
/** Not used */
childCount: number;
bestOf: 3 | 5 | 7;
roundId: number;
// TODO tournament: why we need both?
number: number | null;
position: number;
winnerDestinationMatchId: number | null;
loserDestinationMatchId: number | null;
}
export type TeamOrder = "UPPER" | "LOWER";
export interface TournamentMatchParticipant {
order: TeamOrder;
teamId: number;
matchId: number;
stageId: number;
groupId: number;
number: number;
opponentOne: string; // json
opponentTwo: string; // json
status: Status;
}
export interface TournamentMatchGameResult {
id: number;
matchId: number;
number: number;
stageId: StageId;
mode: ModeShort;
/** serialized TournamentMaplistSource */
source: string;
winnerTeamId: number;
reporterId: number;
createdAt: number;
}
export interface TournamentMatchGameResultParticipant {
matchGameResultId: number;
userId: number;
}
export interface TrustRelationship {
trustGiverUserId: number;
trustReceiverUserId: number;
}
export interface UserSubmittedImage {
id: number;
validatedAt: number | null;

View File

@ -1393,10 +1393,10 @@ function ConsumptionTable({
const opt2 = options2ForThisSubsUsed[i];
const contents = !isComparing
? opt1!.value
? opt1.value
: `${opt1?.value ?? "-"}/${opt2?.value ?? "-"}`;
cells.push(<td key={opt1?.id ?? opt2!.id}>{contents}</td>);
cells.push(<td key={opt1?.id ?? opt2.id}>{contents}</td>);
}
return (

View File

@ -18,7 +18,7 @@ export const action: ActionFunction = async ({ request }) => {
request,
});
validate(isAdmin(user));
validate(isAdmin(user), "Only admins can validate images");
validateImage(data.imageId);

View File

@ -34,14 +34,20 @@ export const action = async ({ request }: ActionArgs) => {
const user = await requireUser(request);
const validatedType = requestToImgType(request);
validate(validatedType);
validate(validatedType, "Invalid image type");
validate(user.team);
validate(user.team, "You must be on a team to upload images");
const detailed = findByIdentifier(user.team.customUrl);
validate(detailed && isTeamOwner({ team: detailed.team, user }));
validate(
detailed && isTeamOwner({ team: detailed.team, user }),
"You must be the team owner to upload images"
);
// TODO: graceful error handling when uploading many images
validate(countUnvalidatedImg(user.id) < MAX_UNVALIDATED_IMG_COUNT);
validate(
countUnvalidatedImg(user.id) < MAX_UNVALIDATED_IMG_COUNT,
"Too many unvalidated images"
);
const uploadHandler: UploadHandler = composeUploadHandlers(
s3UploadHandler,

View File

@ -85,7 +85,7 @@ export const action: ActionFunction = async ({ request, params }) => {
const { team } = notFoundIfFalsy(findByIdentifier(customUrl));
validate(isTeamOwner({ team, user }));
validate(isTeamOwner({ team, user }), "You are not the team owner");
const data = await parseRequestFormData({
request,

View File

@ -43,7 +43,8 @@ export const action: ActionFunction = async ({ request, params }) => {
realInviteCode,
team,
user,
}) === "VALID"
}) === "VALID",
"Invite code is invalid"
);
addNewTeamMember({ teamId: team.id, userId: user.id });

View File

@ -62,7 +62,7 @@ export const action: ActionFunction = async ({ request, params }) => {
const { customUrl } = teamParamsSchema.parse(params);
const { team } = notFoundIfFalsy(findByIdentifier(customUrl));
validate(isTeamOwner({ team, user }));
validate(isTeamOwner({ team, user }), "Only team owner can manage roster");
const data = await parseRequestFormData({
request,
@ -71,7 +71,7 @@ export const action: ActionFunction = async ({ request, params }) => {
switch (data._action) {
case "DELETE_MEMBER": {
validate(data.userId !== user.id);
validate(data.userId !== user.id, "Can't delete yourself");
leaveTeam({ teamId: team.id, userId: data.userId });
break;
}

View File

@ -71,7 +71,10 @@ export const action: ActionFunction = async ({ request, params }) => {
const { customUrl } = teamParamsSchema.parse(params);
const { team } = notFoundIfFalsy(findByIdentifier(customUrl));
validate(isTeamMember({ user, team }) && !isTeamOwner({ user, team }));
validate(
isTeamMember({ user, team }) && !isTeamOwner({ user, team }),
"You are not a regular member of this team"
);
leaveTeam({ userId: user.id, teamId: team.id });

View File

@ -65,9 +65,11 @@ export const action: ActionFunction = async ({ request }) => {
const teams = allTeams();
// user creating team isn't in a team yet
validate(
teams.every((team) => team.members.every((member) => member.id !== user.id))
teams.every((team) =>
team.members.every((member) => member.id !== user.id)
),
"Already in a team"
);
// two teams can't have same customUrl

View File

@ -0,0 +1,48 @@
.brackets-viewer {
/* Colors */
--primary-background: var(--bg);
--secondary-background: var(--bg-lightest);
--match-background: var(--bg-lighter);
--font-color: var(--text);
--win-color: #50b649;
--loss-color: #e61a1a;
--label-color: grey;
--hint-color: #a7a7a7;
/* TODO: mimicking border without transparent but not pretty in light mode */
--connector-color: #1c1b35;
--border-color: var(--primary-background);
--border-hover-color: transparent;
/* Sizes */
--text-size: 12px;
--round-margin: 40px;
--match-width: 150px;
--match-horizontal-padding: 8px;
--match-vertical-padding: 6px;
--connector-border-width: 2px;
--match-border-width: 1px;
--match-border-radius: var(--rounded-sm);
font-family: Lexend, sans-serif !important;
font-weight: var(--semi-bold) !important;
}
.brackets-viewer .opponents.connect-previous::before {
height: 52%;
}
.brackets-viewer .match.connect-next.straight::after {
top: 1px;
}
.brackets-viewer h3 {
border-radius: var(--rounded-sm);
}
.brackets-viewer .bracket h2 {
color: transparent;
}
.brackets-viewer h1 {
display: none;
}

View File

@ -0,0 +1,336 @@
import {
Form,
useActionData,
useLoaderData,
useOutletContext,
} from "@remix-run/react";
import clsx from "clsx";
import { Image } from "~/components/Image";
import { SubmitButton } from "~/components/SubmitButton";
import { useTranslation } from "~/hooks/useTranslation";
import type { ModeShort, StageId } from "~/modules/in-game-lists";
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator";
import { modeImageUrl, stageImageUrl } from "~/utils/urls";
import type { TournamentMatchLoaderData } from "../routes/to.$id.matches.$mid";
import {
HACKY_resolvePoolCode,
mapCountPlayedInSetWithCertainty,
resolveHostingTeam,
resolveRoomPass,
} from "../tournament-bracket-utils";
import { ScoreReporterRosters } from "./ScoreReporterRosters";
import type { SerializeFrom } from "@remix-run/node";
import type { Unpacked } from "~/utils/types";
import type {
TournamentLoaderTeam,
TournamentLoaderData,
} from "~/features/tournament";
import { canAdminTournament } from "~/permissions";
import { useUser } from "~/modules/auth";
import { useIsMounted } from "~/hooks/useIsMounted";
import { databaseTimestampToDate } from "~/utils/dates";
export type Result = Unpacked<
SerializeFrom<TournamentMatchLoaderData>["results"]
>;
export function ScoreReporter({
teams,
currentStageWithMode,
modes,
selectedResultIndex,
setSelectedResultIndex,
result,
type,
}: {
teams: [TournamentLoaderTeam, TournamentLoaderTeam];
currentStageWithMode: TournamentMapListMap;
modes: ModeShort[];
selectedResultIndex?: number;
// if this is set it means the component is being used in presentation manner
setSelectedResultIndex?: (index: number) => void;
result?: Result;
type: "EDIT" | "MEMBER" | "OTHER";
}) {
const isMounted = useIsMounted();
const actionData = useActionData<{ error?: "locked" }>();
const user = useUser();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const data = useLoaderData<TournamentMatchLoaderData>();
const scoreOne = data.match.opponentOne?.score ?? 0;
const scoreTwo = data.match.opponentTwo?.score ?? 0;
const currentPosition = scoreOne + scoreTwo;
const presentational = Boolean(setSelectedResultIndex);
const showFullInfos =
!presentational && (type === "EDIT" || type === "MEMBER");
const roundInfos = [
showFullInfos ? (
<>
<b>{resolveHostingTeam(teams).name}</b> hosts
</>
) : null,
showFullInfos ? (
<>
Pass <b>{resolveRoomPass(data.match.id)}</b>
</>
) : null,
showFullInfos ? (
<>
Pool <b>{HACKY_resolvePoolCode(parentRouteData.event)}</b>
</>
) : null,
<>
<b>
{scoreOne}-{scoreTwo}
</b>{" "}
(Best of {data.match.bestOf})
</>,
];
const matchIsLockedError = actionData?.error === "locked";
return (
<div className="tournament-bracket__during-match-actions">
<FancyStageBanner
stage={currentStageWithMode}
infos={roundInfos}
teams={teams}
>
{currentPosition > 0 && !presentational && type === "EDIT" && (
<Form method="post">
<input type="hidden" name="position" value={currentPosition - 1} />
<div className="tournament-bracket__stage-banner__bottom-bar">
<SubmitButton
_action="UNDO_REPORT_SCORE"
className="tournament-bracket__stage-banner__undo-button"
>
Undo last score
</SubmitButton>
</div>
</Form>
)}
{canAdminTournament({ user, event: parentRouteData.event }) &&
presentational &&
!matchIsLockedError && (
<Form method="post">
<div className="tournament-bracket__stage-banner__bottom-bar">
<SubmitButton
_action="REOPEN_MATCH"
className="tournament-bracket__stage-banner__undo-button"
>
Reopen match
</SubmitButton>
</div>
</Form>
)}
{matchIsLockedError && (
<div className="tournament-bracket__stage-banner__bottom-bar">
<SubmitButton
_action="REOPEN_MATCH"
className="tournament-bracket__stage-banner__undo-button"
disabled
>
Match is locked
</SubmitButton>
</div>
)}
</FancyStageBanner>
<ModeProgressIndicator
modes={modes}
scores={[scoreOne, scoreTwo]}
bestOf={data.match.bestOf}
selectedResultIndex={selectedResultIndex}
setSelectedResultIndex={setSelectedResultIndex}
/>
{type === "EDIT" || presentational ? (
<ActionSectionWrapper>
<ScoreReporterRosters
// Without the key prop when switching to another match the winnerId is remembered
// which causes "No winning team matching the id" error.
// Switching the key props forces the component to remount.
key={data.match.id}
teams={teams}
position={currentPosition}
currentStageWithMode={currentStageWithMode}
result={result}
/>
</ActionSectionWrapper>
) : null}
{result ? (
<div
className={clsx("text-center text-xs text-lighter", {
invisible: !isMounted,
})}
>
{isMounted
? databaseTimestampToDate(result.createdAt).toLocaleString()
: "t"}
</div>
) : null}
</div>
);
}
function FancyStageBanner({
stage,
infos,
children,
teams,
}: {
stage: TournamentMapListMap;
infos?: (JSX.Element | null)[];
children?: React.ReactNode;
teams: [TournamentLoaderTeam, TournamentLoaderTeam];
}) {
const { t } = useTranslation(["game-misc", "tournament"]);
const stageNameToBannerImageUrl = (stageId: StageId) => {
return stageImageUrl(stageId) + ".png";
};
const style = {
"--_tournament-bg-url": `url("${stageNameToBannerImageUrl(
stage.stageId
)}")`,
};
const pickInfoText = () => {
if (stage.source === teams[0].id)
return t("tournament:pickInfo.team", { number: 1 });
if (stage.source === teams[1].id)
return t("tournament:pickInfo.team", { number: 2 });
if (stage.source === "TIEBREAKER")
return t("tournament:pickInfo.tiebreaker");
if (stage.source === "BOTH") return t("tournament:pickInfo.both");
if (stage.source === "DEFAULT") return t("tournament:pickInfo.default");
console.error(`Unknown source: ${String(stage.source)}`);
return "";
};
return (
<>
<div
className={clsx("tournament-bracket__stage-banner", {
rounded: !infos,
})}
style={style as any}
>
<div className="tournament-bracket__stage-banner__top-bar">
<h4 className="tournament-bracket__stage-banner__top-bar__header">
<Image
className="tournament-bracket__stage-banner__top-bar__mode-image"
path={modeImageUrl(stage.mode)}
alt=""
width={24}
/>
{t(`game-misc:MODE_LONG_${stage.mode}`)} on{" "}
{t(`game-misc:STAGE_${stage.stageId}`)}
</h4>
<h4>{pickInfoText()}</h4>
</div>
{children}
</div>
{infos && (
<div className="tournament-bracket__infos">
{infos.filter(Boolean).map((info, i) => (
<div key={i}>{info}</div>
))}
</div>
)}
</>
);
}
function ModeProgressIndicator({
modes,
scores,
bestOf,
selectedResultIndex,
setSelectedResultIndex,
}: {
modes: ModeShort[];
scores: [number, number];
bestOf: number;
selectedResultIndex?: number;
setSelectedResultIndex?: (index: number) => void;
}) {
const data = useLoaderData<TournamentMatchLoaderData>();
const { t } = useTranslation(["game-misc"]);
const maxIndexThatWillBePlayedForSure =
mapCountPlayedInSetWithCertainty({ bestOf, scores }) - 1;
// TODO: this should be button when we click on it
return (
<div className="tournament-bracket__mode-progress">
{modes.map((mode, i) => {
return (
<Image
containerClassName={clsx(
"tournament-bracket__mode-progress__image",
{
"tournament-bracket__mode-progress__image__notable":
i <= maxIndexThatWillBePlayedForSure,
"tournament-bracket__mode-progress__image__team-one-win":
data.results[i] &&
data.results[i]!.winnerTeamId === data.match.opponentOne?.id,
"tournament-bracket__mode-progress__image__team-two-win":
data.results[i] &&
data.results[i]!.winnerTeamId === data.match.opponentTwo?.id,
"tournament-bracket__mode-progress__image__selected":
i === selectedResultIndex,
"cursor-pointer": Boolean(setSelectedResultIndex),
}
)}
key={i}
path={modeImageUrl(mode)}
height={20}
width={20}
alt={t(`game-misc:MODE_LONG_${mode}`)}
title={t(`game-misc:MODE_LONG_${mode}`)}
onClick={() => setSelectedResultIndex?.(i)}
/>
);
})}
</div>
);
}
function ActionSectionWrapper({
children,
icon,
...rest
}: {
children: React.ReactNode;
icon?: "warning" | "info" | "success" | "error";
"justify-center"?: boolean;
"data-cy"?: string;
}) {
// todo: flex-dir: column on mobile
const style = icon
? {
"--action-section-icon-color": `var(--theme-${icon})`,
}
: undefined;
return (
<section
className="tournament__action-section"
style={style as any}
data-cy={rest["data-cy"]}
>
<div
className={clsx("tournament__action-section__content", {
"justify-center": rest["justify-center"],
})}
>
{children}
</div>
</section>
);
}

View File

@ -0,0 +1,153 @@
import * as React from "react";
import { Form } from "@remix-run/react";
import type {
TournamentLoaderData,
TournamentLoaderTeam,
} from "../../tournament/routes/to.$id";
import type { Unpacked } from "~/utils/types";
import { TOURNAMENT } from "../../tournament/tournament-constants";
import { SubmitButton } from "~/components/SubmitButton";
import { TeamRosterInputs } from "./TeamRosterInputs";
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator";
import { useTranslation } from "~/hooks/useTranslation";
import type { Result } from "./ScoreReporter";
export function ScoreReporterRosters({
teams,
position,
currentStageWithMode,
result,
}: {
teams: [TournamentLoaderTeam, TournamentLoaderTeam];
position: number;
currentStageWithMode: TournamentMapListMap;
result?: Result;
}) {
const [checkedPlayers, setCheckedPlayers] = React.useState<
[number[], number[]]
>(checkedPlayersInitialState(teams));
const [winnerId, setWinnerId] = React.useState<number | undefined>();
const presentational = Boolean(result);
return (
<Form method="post" className="width-full">
<div>
<TeamRosterInputs
teams={teams}
winnerId={winnerId}
setWinnerId={setWinnerId}
checkedPlayers={checkedPlayers}
setCheckedPlayers={setCheckedPlayers}
result={result}
/>
{!presentational ? (
<div className="tournament-bracket__during-match-actions__actions">
<input type="hidden" name="winnerTeamId" value={winnerId ?? ""} />
<input
type="hidden"
name="playerIds"
value={JSON.stringify(checkedPlayers.flat())}
/>
<input type="hidden" name="position" value={position} />
<input
type="hidden"
name="stageId"
value={currentStageWithMode.stageId}
/>
<input
type="hidden"
name="mode"
value={currentStageWithMode.mode}
/>
<input
type="hidden"
name="source"
value={currentStageWithMode.source}
/>
<ReportScoreButtons
checkedPlayers={checkedPlayers}
winnerName={winningTeam()}
currentStageWithMode={currentStageWithMode}
/>
</div>
) : null}
</div>
</Form>
);
function winningTeam() {
if (!winnerId) return;
if (teams[0].id === winnerId) return teams[0].name;
if (teams[1].id === winnerId) return teams[1].name;
throw new Error("No winning team matching the id");
}
}
// TODO: remember what previously selected for our team
function checkedPlayersInitialState([teamOne, teamTwo]: [
Unpacked<TournamentLoaderData["teams"]>,
Unpacked<TournamentLoaderData["teams"]>
]): [number[], number[]] {
const result: [number[], number[]] = [[], []];
if (teamOne.members.length === TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL) {
result[0].push(...teamOne.members.map((member) => member.userId));
}
if (teamTwo.members.length === TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL) {
result[1].push(...teamTwo.members.map((member) => member.userId));
}
return result;
}
function ReportScoreButtons({
checkedPlayers,
winnerName,
currentStageWithMode,
}: {
checkedPlayers: number[][];
winnerName?: string;
currentStageWithMode: TournamentMapListMap;
}) {
const { t } = useTranslation(["game-misc"]);
if (
!checkedPlayers.every(
(team) => team.length === TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL
)
) {
return (
<p className="tournament-bracket__during-match-actions__amount-warning-paragraph">
Please choose exactly {TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL}+
{TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL} players to report score
</p>
);
}
if (!winnerName) {
return (
<p className="tournament-bracket__during-match-actions__amount-warning-paragraph">
Please select the winner of this map
</p>
);
}
return (
<div className="stack sm items-center">
<div className="tournament-bracket__during-match-actions__confirm-score-text">
Report <b>{winnerName}</b> win on{" "}
<b>
{t(`game-misc:MODE_LONG_${currentStageWithMode.mode}`)}{" "}
{t(`game-misc:STAGE_${currentStageWithMode.stageId}`)}
</b>
?
</div>
<SubmitButton size="tiny" _action="REPORT_SCORE">
Report
</SubmitButton>
</div>
);
}

View File

@ -0,0 +1,200 @@
import clsx from "clsx";
import clone from "just-clone";
import * as React from "react";
import { TOURNAMENT } from "../../tournament/tournament-constants";
import { Label } from "~/components/Label";
import type {
TournamentLoaderData,
TournamentLoaderTeam,
} from "../../tournament/routes/to.$id";
import type { Unpacked } from "~/utils/types";
import { inGameNameWithoutDiscriminator } from "~/utils/strings";
import { useLoaderData } from "@remix-run/react";
import type { TournamentMatchLoaderData } from "../routes/to.$id.matches.$mid";
import type { Result } from "./ScoreReporter";
export type TeamRosterInputsType = "DEFAULT" | "DISABLED" | "PRESENTATIONAL";
/** Inputs to select who played for teams in a match as well as the winner. Can also be used in a presentational way. */
export function TeamRosterInputs({
teams,
winnerId,
setWinnerId,
checkedPlayers,
setCheckedPlayers,
result,
}: {
teams: [TournamentLoaderTeam, TournamentLoaderTeam];
winnerId?: number | null;
setWinnerId: (newId?: number) => void;
checkedPlayers: [number[], number[]];
setCheckedPlayers?: (newPlayerIds: [number[], number[]]) => void;
result?: Result;
}) {
const presentational = Boolean(result);
const data = useLoaderData<TournamentMatchLoaderData>();
const inputMode = (
team: Unpacked<TournamentLoaderData["teams"]>
): TeamRosterInputsType => {
if (presentational) return "PRESENTATIONAL";
// Disabled in this case because we expect a result to have exactly
// TOURNAMENT_TEAM_ROSTER_MIN_SIZE members per team when reporting it
// so there is no point to let user to change them around
if (team.members.length <= TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL) {
return "DISABLED";
}
return "DEFAULT";
};
React.useEffect(() => {
setWinnerId(undefined);
}, [data, setWinnerId]);
return (
<div className="tournament-bracket__during-match-actions__rosters">
{teams.map((team, teamI) => (
<div key={team.id}>
<div className="text-xs text-lighter font-semi-bold stack horizontal xs items-center justify-center">
<div
className={
teamI === 0
? "tournament-bracket__team-one-dot"
: "tournament-bracket__team-two-dot"
}
/>
Team {teamI + 1}
</div>
<h4>{team.name}</h4>
<WinnerRadio
presentational={presentational}
checked={
result ? result.winnerTeamId === team.id : winnerId === team.id
}
teamId={team.id}
onChange={() => setWinnerId?.(team.id)}
team={teamI + 1}
/>
<TeamRosterInputsCheckboxes
team={team}
checkedPlayers={result?.participantIds ?? checkedPlayers[teamI]!}
mode={inputMode(team)}
handlePlayerClick={(playerId: number) => {
const newCheckedPlayers = () => {
const newPlayers = clone(checkedPlayers);
if (checkedPlayers.flat().includes(playerId)) {
newPlayers[teamI] = newPlayers[teamI]!.filter(
(id) => id !== playerId
);
} else {
newPlayers[teamI]!.push(playerId);
}
return newPlayers;
};
setCheckedPlayers?.(newCheckedPlayers());
}}
/>
</div>
))}
</div>
);
}
/** Renders radio button to select winner, or in presentational mode just display the text "Winner" */
function WinnerRadio({
presentational,
teamId,
checked,
onChange,
team,
}: {
presentational: boolean;
teamId: number;
checked: boolean;
onChange: () => void;
team: number;
}) {
const id = React.useId();
if (presentational) {
return (
<div
className={clsx("text-xs font-bold", {
invisible: !checked,
"text-theme": team === 1,
"text-theme-secondary": team === 2,
})}
>
Winner
</div>
);
}
return (
<div className="tournament-bracket__during-match-actions__radio-container">
<input
type="radio"
id={`${teamId}-${id}`}
onChange={onChange}
checked={checked}
/>
<Label className="mb-0 ml-2" htmlFor={`${teamId}-${id}`}>
Winner
</Label>
</div>
);
}
function TeamRosterInputsCheckboxes({
team,
checkedPlayers,
handlePlayerClick,
mode,
}: {
team: Unpacked<TournamentLoaderData["teams"]>;
checkedPlayers: number[];
handlePlayerClick: (playerId: number) => void;
/** DEFAULT = inputs work, DISABLED = inputs disabled and look disabled, PRESENTATION = inputs disabled but look like in DEFAULT (without hover styles) */
mode: TeamRosterInputsType;
}) {
const id = React.useId();
return (
<div className="tournament-bracket__during-match-actions__team-players">
{team.members.map((member) => {
return (
<div
key={member.userId}
className={clsx(
"tournament-bracket__during-match-actions__checkbox-name",
{ "disabled-opaque": mode === "DISABLED" },
{ presentational: mode === "PRESENTATIONAL" }
)}
>
<input
className="plain tournament-bracket__during-match-actions__checkbox"
type="checkbox"
id={`${member.userId}-${id}`}
name="playerName"
disabled={mode === "DISABLED" || mode === "PRESENTATIONAL"}
value={member.userId}
checked={checkedPlayers.flat().includes(member.userId)}
onChange={() => handlePlayerClick(member.userId)}
/>{" "}
<label
className="tournament-bracket__during-match-actions__player-name"
htmlFor={`${member.userId}-${id}`}
>
{member.inGameName
? inGameNameWithoutDiscriminator(member.inGameName)
: member.discordName}
</label>
</div>
);
})}
</div>
);
}

View File

@ -0,0 +1,55 @@
import invariant from "tiny-invariant";
import type { FindAllMatchesByTournamentIdMatch } from "../queries/findAllMatchesByTournamentId.server";
// TODO: this only works for double elimination
export function resolveBestOfs(
matches: Array<FindAllMatchesByTournamentIdMatch>
) {
// 3 is default
const result: [bestOf: 5 | 7, id: number][] = [];
/// Best of 7
// 1) Grand Finals
// 2) Bracket reset
const finalsMatches = matches.filter((match) => match.groupNumber === 3);
invariant(finalsMatches.length === 2, "finalsMatches must be 2");
result.push([7, finalsMatches[0]!.matchId]);
result.push([7, finalsMatches[1]!.matchId]);
/// Best of 5
// 1) All rounds of Winners except the first two, Grand Finals and Bracket Reset.
const bestOfFiveWinnersRounds = matches.filter(
(match) =>
match.groupNumber === 1 &&
match.roundNumber > 2 &&
!finalsMatches.some(
(finalsMatch) => finalsMatch.matchId === match.matchId
)
);
for (const match of bestOfFiveWinnersRounds) {
result.push([5, match.matchId]);
}
// 2) Losers Finals.
const maxLosersRoundNumber = Math.max(
...matches
.filter((match) => match.groupNumber === 2)
.map((match) => match.roundNumber)
);
const losersFinals = matches.filter(
(match) => match.roundNumber === maxLosersRoundNumber
);
invariant(losersFinals.length === 1, "losersFinals must be 1");
result.push([5, losersFinals[0]!.matchId]);
return result;
}

View File

@ -0,0 +1,475 @@
// this file offers database functions specifically for the crud.server.ts file
import type {
Participant,
Stage as StageType,
Group as GroupType,
Round as RoundType,
Match as MatchType,
} from "brackets-model";
import { sql } from "~/db/sql";
import type {
Tournament,
TournamentGroup,
TournamentMatch,
TournamentRound,
TournamentStage,
TournamentTeam,
} from "~/db/types";
const team_getByTournamentIdStm = sql.prepare(/*sql*/ `
select
*
from
"TournamentTeam"
where
"TournamentTeam"."tournamentId" = @tournamentId
`);
export class Team {
static #convertTeam(rawTeam: TournamentTeam): Participant {
return {
id: rawTeam.id,
name: rawTeam.name,
tournament_id: rawTeam.tournamentId,
};
}
static getByTournamentId(tournamentId: Tournament["id"]): Participant[] {
return team_getByTournamentIdStm
.all({ tournamentId })
.map(this.#convertTeam);
}
}
const stage_getByIdStm = sql.prepare(/*sql*/ `
select
*
from
"TournamentStage"
where
"TournamentStage"."id" = @id
`);
const stage_getByTournamentIdStm = sql.prepare(/*sql*/ `
select
*
from
"TournamentStage"
where
"TournamentStage"."tournamentId" = @tournamentId
`);
const stage_insertStm = sql.prepare(/*sql*/ `
insert into
"TournamentStage"
("tournamentId", "number", "name", "type", "settings")
values
(@tournamentId, @number, @name, @type, @settings)
returning *
`);
const stage_updateSettingsStm = sql.prepare(/*sql*/ `
update
"TournamentStage"
set
"settings" = @settings
where
"TournamentStage"."id" = @id
`);
export class Stage {
id?: TournamentStage["id"];
tournamentId: TournamentStage["tournamentId"];
number: TournamentStage["number"];
name: TournamentStage["name"];
type: StageType["type"];
settings: TournamentStage["settings"];
constructor(
id: TournamentStage["id"] | undefined,
tournamentId: TournamentStage["tournamentId"],
number: TournamentStage["number"],
name: TournamentStage["name"],
type: StageType["type"],
settings: TournamentStage["settings"]
) {
this.id = id;
this.tournamentId = tournamentId;
this.number = number;
this.name = name;
this.type = type;
this.settings = settings;
}
insert() {
const stage = stage_insertStm.get({
tournamentId: this.tournamentId,
number: this.number,
name: this.name,
type: this.type,
settings: this.settings,
});
this.id = stage.id;
return true;
}
static #convertStage(rawStage: TournamentStage): StageType {
return {
id: rawStage.id,
name: rawStage.name,
number: rawStage.number,
settings: JSON.parse(rawStage.settings),
tournament_id: rawStage.tournamentId,
type: rawStage.type,
};
}
static getById(id: TournamentStage["id"]): StageType {
const stage = stage_getByIdStm.get({ id });
if (!stage) return stage;
return this.#convertStage(stage);
}
static getByTournamentId(tournamentId: Tournament["id"]): Participant[] {
return stage_getByTournamentIdStm
.all({ tournamentId })
.map(this.#convertStage);
}
static updateSettings(
id: TournamentStage["id"],
settings: TournamentStage["settings"]
) {
stage_updateSettingsStm.run({ id, settings });
return true;
}
}
const group_getByIdStm = sql.prepare(/*sql*/ `
select *
from "TournamentGroup"
where "TournamentGroup"."id" = @id
`);
const group_getByStageIdStm = sql.prepare(/*sql*/ `
select *
from "TournamentGroup"
where "TournamentGroup"."stageId" = @stageId
`);
const group_getByStageAndNumberStm = sql.prepare(/*sql*/ `
select *
from "TournamentGroup"
where "TournamentGroup"."stageId" = @stageId
and "TournamentGroup"."number" = @number
`);
const group_insertStm = sql.prepare(/*sql*/ `
insert into
"TournamentGroup"
("stageId", "number")
values
(@stageId, @number)
returning *
`);
export class Group {
id?: TournamentGroup["id"];
stageId: TournamentGroup["stageId"];
number: TournamentGroup["number"];
constructor(
id: TournamentGroup["id"] | undefined,
stageId: TournamentGroup["stageId"],
number: TournamentGroup["number"]
) {
this.id = id;
this.stageId = stageId;
this.number = number;
}
static #convertGroup(rawGroup: TournamentGroup): GroupType {
return {
id: rawGroup.id,
number: rawGroup.number,
stage_id: rawGroup.stageId,
};
}
static getById(id: TournamentGroup["id"]): GroupType {
const group = group_getByIdStm.get({ id });
if (!group) return group;
return this.#convertGroup(group);
}
static getByStageId(stageId: TournamentStage["id"]): GroupType[] {
return group_getByStageIdStm.all({ stageId }).map(this.#convertGroup);
}
static getByStageAndNumber(
stageId: TournamentStage["id"],
number: TournamentGroup["number"]
): GroupType {
const group = group_getByStageAndNumberStm.get({ stageId, number });
if (!group) return group;
return this.#convertGroup(group_getByStageAndNumberStm.get(group));
}
insert() {
const group = group_insertStm.get({
stageId: this.stageId,
number: this.number,
});
this.id = group.id;
return true;
}
}
const round_getByIdStm = sql.prepare(/*sql*/ `
select *
from "TournamentRound"
where "TournamentRound"."id" = @id
`);
const round_getByGroupIdStm = sql.prepare(/*sql*/ `
select *
from "TournamentRound"
where "TournamentRound"."groupId" = @groupId
`);
const round_getByStageIdStm = sql.prepare(/*sql*/ `
select *
from "TournamentRound"
where "TournamentRound"."stageId" = @stageId
`);
const round_getByGroupAndNumberStm = sql.prepare(/*sql*/ `
select *
from "TournamentRound"
where "TournamentRound"."groupId" = @groupId
and "TournamentRound"."number" = @number
`);
const round_insertStm = sql.prepare(/*sql*/ `
insert into
"TournamentRound"
("stageId", "groupId", "number")
values
(@stageId, @groupId, @number)
returning *
`);
export class Round {
id?: TournamentRound["id"];
stageId: TournamentRound["stageId"];
groupId: TournamentRound["groupId"];
number: TournamentRound["number"];
constructor(
id: TournamentRound["id"] | undefined,
stageId: TournamentRound["stageId"],
groupId: TournamentRound["groupId"],
number: TournamentRound["number"]
) {
this.id = id;
this.stageId = stageId;
this.groupId = groupId;
this.number = number;
}
insert() {
const round = round_insertStm.get({
stageId: this.stageId,
groupId: this.groupId,
number: this.number,
});
this.id = round.id;
return true;
}
static #convertRound(rawRound: TournamentRound): RoundType {
return {
id: rawRound.id,
group_id: rawRound.groupId,
number: rawRound.number,
stage_id: rawRound.stageId,
};
}
static getByStageId(stageId: TournamentStage["id"]): RoundType[] {
return round_getByStageIdStm.all({ stageId }).map(this.#convertRound);
}
static getByGroupId(groupId: TournamentGroup["id"]): RoundType[] {
return round_getByGroupIdStm.all({ groupId }).map(this.#convertRound);
}
static getByGroupAndNumber(
groupId: TournamentGroup["id"],
number: TournamentRound["number"]
): RoundType {
const round = round_getByGroupAndNumberStm.get({ groupId, number });
if (!round) return round;
return this.#convertRound(round);
}
static getById(id: TournamentRound["id"]): RoundType {
const round = round_getByIdStm.get({ id });
if (!round) return round;
return this.#convertRound(round);
}
}
const match_getByIdStm = sql.prepare(/*sql*/ `
select *
from "TournamentMatch"
where "TournamentMatch"."id" = @id
`);
const match_getByStageIdStm = sql.prepare(/*sql*/ `
select *
from "TournamentMatch"
where "TournamentMatch"."stageId" = @stageId
`);
const match_getByRoundAndNumberStm = sql.prepare(/*sql*/ `
select *
from "TournamentMatch"
where "TournamentMatch"."roundId" = @roundId
and "TournamentMatch"."number" = @number
`);
const match_insertStm = sql.prepare(/*sql*/ `
insert into
"TournamentMatch"
("childCount", "roundId", "stageId", "groupId", "number", "opponentOne", "opponentTwo", "status")
values
(@childCount, @roundId, @stageId, @groupId, @number, @opponentOne, @opponentTwo, @status)
returning *
`);
const match_updateStm = sql.prepare(/*sql*/ `
update "TournamentMatch"
set
"childCount" = @childCount,
"roundId" = @roundId,
"stageId" = @stageId,
"groupId" = @groupId,
"number" = @number,
"opponentOne" = @opponentOne,
"opponentTwo" = @opponentTwo,
"status" = @status
where
"TournamentMatch"."id" = @id
`);
export class Match {
id?: TournamentMatch["id"];
childCount: TournamentMatch["childCount"];
roundId: TournamentMatch["roundId"];
stageId: TournamentMatch["stageId"];
groupId: TournamentMatch["groupId"];
number: TournamentMatch["number"];
opponentOne: TournamentMatch["opponentOne"];
opponentTwo: TournamentMatch["opponentTwo"];
status: TournamentMatch["status"];
constructor(
id: TournamentMatch["id"] | undefined,
status: TournamentMatch["status"],
stageId: TournamentMatch["stageId"],
groupId: TournamentMatch["groupId"],
roundId: TournamentMatch["roundId"],
number: TournamentMatch["number"],
childCount: TournamentMatch["childCount"],
_unknown1: null,
_unknown2: null,
_unknown3: null,
opponentOne: TournamentMatch["opponentOne"],
opponentTwo: TournamentMatch["opponentTwo"]
) {
this.id = id;
this.childCount = childCount;
this.roundId = roundId;
this.stageId = stageId;
this.groupId = groupId;
this.number = number;
this.opponentOne = opponentOne;
this.opponentTwo = opponentTwo;
this.status = status;
}
static #convertMatch(rawMatch: TournamentMatch): MatchType {
return {
id: rawMatch.id,
child_count: rawMatch.childCount,
group_id: rawMatch.groupId,
number: rawMatch.number,
opponent1: JSON.parse(rawMatch.opponentOne),
opponent2: JSON.parse(rawMatch.opponentTwo),
round_id: rawMatch.roundId,
stage_id: rawMatch.stageId,
status: rawMatch.status,
};
}
static getById(id: TournamentMatch["id"]): MatchType {
const match = match_getByIdStm.get({ id });
if (!match) return match;
return this.#convertMatch(match);
}
static getByStageId(stageId: TournamentStage["id"]): MatchType[] {
return match_getByStageIdStm.all({ stageId }).map(this.#convertMatch);
}
static getByRoundAndNumber(
roundId: TournamentRound["id"],
number: TournamentMatch["number"]
): MatchType {
const match = match_getByRoundAndNumberStm.get({ roundId, number });
if (!match) return match;
return this.#convertMatch(match);
}
insert() {
const match = match_insertStm.get({
childCount: this.childCount,
roundId: this.roundId,
stageId: this.stageId,
groupId: this.groupId,
number: this.number,
opponentOne: this.opponentOne,
opponentTwo: this.opponentTwo,
status: this.status,
});
this.id = match.id;
return true;
}
update() {
match_updateStm.run({
id: this.id,
childCount: this.childCount,
roundId: this.roundId,
stageId: this.stageId,
groupId: this.groupId,
number: this.number,
opponentOne: this.opponentOne,
opponentTwo: this.opponentTwo,
status: this.status,
});
return true;
}
}

View File

@ -0,0 +1,347 @@
/* eslint-disable */
// @ts-nocheck TODO
import { Stage, Team, Group, Round, Match } from "./crud-db.server";
export class SqlDatabase {
insert(table, arg) {
switch (table) {
case "participant":
throw new Error("not implemented");
return Team.insertMissing(arg);
case "stage":
const stage = new Stage(
undefined,
arg.tournament_id,
arg.number,
arg.name,
arg.type,
JSON.stringify(arg.settings)
);
return stage.insert() && stage.id;
case "group":
const group = new Group(undefined, arg.stage_id, arg.number);
return group.insert() && group.id;
case "round":
const round = new Round(
undefined,
arg.stage_id,
arg.group_id,
arg.number
);
return round.insert() && round.id;
case "match":
const match = new Match(
undefined,
arg.status,
arg.stage_id,
arg.group_id,
arg.round_id,
arg.number,
arg.child_count,
null,
null,
null,
JSON.stringify(arg.opponent1),
JSON.stringify(arg.opponent2)
);
return match.insert() && match.id;
case "match_game":
throw new Error("not implemented");
const matchGame = new MatchGame(
undefined,
arg.stage_id,
arg.parent_id,
arg.status,
arg.number,
null,
null,
null,
JSON.stringify(arg.opponent1),
JSON.stringify(arg.opponent2)
);
return matchGame.insert() && matchGame.id;
}
}
select(table, arg) {
switch (table) {
case "participant":
if (typeof arg === "number") {
throw new Error("not implemented");
const team = Team.getById(arg);
return team && convertTeam(team);
}
if (arg.tournament_id) {
return Team.getByTournamentId(arg.tournament_id);
}
break;
case "stage":
if (typeof arg === "number") {
return Stage.getById(arg);
}
if (arg.tournament_id && arg.number) {
throw new Error("not implemented");
const stage = Stage.getByTournamentAndNumber(
arg.tournament_id,
arg.number
);
return stage && [convertStage(stage)];
}
if (arg.tournament_id) {
return Stage.getByTournamentId(arg.tournament_id);
}
break;
case "group":
if (!arg) {
throw new Error("not implemented");
const groups = Group.getAll();
return groups && groups.map(convertGroup);
}
if (typeof arg === "number") {
return Group.getById(arg);
}
if (arg.stage_id && arg.number) {
const group = Group.getByStageAndNumber(arg.stage_id, arg.number);
return group && [group];
}
if (arg.stage_id) {
return Group.getByStageId(arg.stage_id);
}
break;
case "round":
if (!arg) {
throw new Error("not implemented");
const rounds = Round.getAll();
return rounds && rounds.map(convertRound);
}
if (typeof arg === "number") {
return Round.getById(arg);
}
if (arg.group_id && arg.number) {
const round = Round.getByGroupAndNumber(arg.group_id, arg.number);
return round && [round];
}
if (arg.group_id) {
return Round.getByGroupId(arg.group_id);
}
if (arg.stage_id) {
return Round.getByStageId(arg.stage_id);
}
break;
case "match":
if (!arg) {
throw new Error("not implemented");
const matches = Match.getAll();
return matches && matches.map(convertMatch);
}
if (typeof arg === "number") {
return Match.getById(arg);
}
if (arg.round_id && arg.number) {
const match = Match.getByRoundAndNumber(arg.round_id, arg.number);
return match && [match];
}
if (arg.stage_id) {
return Match.getByStageId(arg.stage_id);
}
if (arg.group_id) {
throw new Error("not implemented");
const matches = Match.getByGroupId(arg.group_id);
return matches && matches.map(convertMatch);
}
if (arg.round_id) {
throw new Error("not implemented");
const matches = Match.getByRoundId(arg.round_id);
return matches && matches.map(convertMatch);
}
break;
case "match_game":
throw new Error("not implemented");
if (typeof arg === "number") {
const game = MatchGame.getById(arg);
return game && convertMatchGame(game);
}
if (arg.parent_id && arg.number) {
const game = MatchGame.getByParentAndNumber(
arg.parent_id,
arg.number
);
return game && [convertMatchGame(game)];
}
if (arg.parent_id) {
const games = MatchGame.getByParentId(arg.parent_id);
return games && games.map(convertMatchGame);
}
break;
}
return null;
}
update(table, query, update) {
switch (table) {
case "stage":
if (typeof query === "number") {
return Stage.updateSettings(query, JSON.stringify(update.settings));
}
break;
case "match":
if (typeof query === "number") {
const match = new Match(
query,
update.status,
update.stage_id,
update.group_id,
update.round_id,
update.number,
update.child_count,
null,
null,
null,
JSON.stringify(update.opponent1),
JSON.stringify(update.opponent2)
);
return match.update();
}
if (query.stage_id)
return Match.updateChildCountByStage(
query.stage_id,
update.child_count
);
if (query.group_id)
return Match.updateChildCountByGroup(
query.group_id,
update.child_count
);
if (query.round_id)
return Match.updateChildCountByRound(
query.round_id,
update.child_count
);
break;
case "match_game":
throw new Error("not implemented");
if (typeof query === "number") {
const game = new MatchGame(
query,
update.stage_id,
update.parent_id,
update.status,
update.number,
null,
null,
null,
JSON.stringify(update.opponent1),
JSON.stringify(update.opponent2)
);
return game.update();
}
if (query.parent_id) {
const game = new MatchGame(
undefined,
update.stage_id,
query.parent_id,
update.status,
update.number,
null,
null,
null,
JSON.stringify(update.opponent1),
JSON.stringify(update.opponent2)
);
return game.updateByParentId();
}
break;
}
return false;
}
delete(table, filter) {
throw new Error("not implemented");
switch (table) {
case "stage":
return Number.isInteger(filter.id) && Stage.deleteById(filter.id);
case "group":
return (
Number.isInteger(filter.stage_id) &&
Group.deleteByStageId(filter.stage_id)
);
case "round":
return (
Number.isInteger(filter.stage_id) &&
Round.deleteByStageId(filter.stage_id)
);
case "match":
return (
Number.isInteger(filter.stage_id) &&
Match.deleteByStageId(filter.stage_id)
);
case "match_game":
if (Number.isInteger(filter.stage_id))
return MatchGame.deleteByStageId(filter.stage_id);
if (
Number.isInteger(filter.parent_id) &&
Number.isInteger(filter.number)
)
return MatchGame.deleteByParentAndNumber(
filter.parent_id,
filter.number
);
else return false;
default:
return false;
}
}
}

View File

@ -0,0 +1 @@
export { getTournamentManager } from "./manager";

View File

@ -0,0 +1,13 @@
import { InMemoryDatabase } from "~/modules/brackets-memory-db";
import { SqlDatabase } from "./crud.server";
import { BracketsManager } from "~/modules/brackets-manager";
export function getTournamentManager(type: "SQL" | "IN_MEMORY") {
const storage =
type === "IN_MEMORY" ? new InMemoryDatabase() : new SqlDatabase();
// TODO: fix this ts-expect-error comment
// @ts-expect-error interface mismatch
const manager = new BracketsManager(storage);
return manager;
}

View File

@ -0,0 +1,10 @@
import { EventEmitter } from "events";
const globalForEmitter = global as unknown as {
emitter: EventEmitter | undefined;
};
export const emitter = globalForEmitter.emitter ?? new EventEmitter();
// xxx: test behavior when deployed, do we need if (process.env.NODE_ENV !== 'production') ?
globalForEmitter.emitter = emitter;

View File

@ -0,0 +1,10 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/* sql */ `
delete from "TournamentMatchGameResult"
where "TournamentMatchGameResult"."id" = @id
`);
export function deleteTournamentMatchGameResultById(id: number) {
stm.run({ id });
}

View File

@ -0,0 +1,25 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/* sql */ `
select
"TournamentMatch"."id" as "matchId",
"TournamentRound"."number" as "roundNumber",
"TournamentGroup"."number" as "groupNumber"
from "TournamentMatch"
left join "TournamentRound" on "TournamentRound"."id" = "TournamentMatch"."roundId"
left join "TournamentGroup" on "TournamentGroup"."id" = "TournamentMatch"."groupId"
left join "TournamentStage" on "TournamentStage"."id" = "TournamentMatch"."stageId"
where "TournamentStage"."tournamentId" = @tournamentId
`);
export interface FindAllMatchesByTournamentIdMatch {
matchId: number;
roundNumber: number;
groupNumber: number;
}
export function findAllMatchesByTournamentId(
tournamentId: number
): Array<FindAllMatchesByTournamentIdMatch> {
return stm.all({ tournamentId });
}

View File

@ -0,0 +1,29 @@
import type { Match } from "brackets-model";
import { sql } from "~/db/sql";
import type { TournamentMatch } from "~/db/types";
const stm = sql.prepare(/* sql */ `
select
id,
opponentOne,
opponentTwo,
bestOf
from "TournamentMatch"
where id = @id
`);
export type FindMatchById = ReturnType<typeof findMatchById>;
export const findMatchById = (id: number) => {
const row = stm.get({ id }) as
| Pick<TournamentMatch, "id" | "opponentOne" | "opponentTwo" | "bestOf">
| undefined;
if (!row) return;
return {
...row,
opponentOne: JSON.parse(row.opponentOne) as Match["opponent1"],
opponentTwo: JSON.parse(row.opponentTwo) as Match["opponent2"],
};
};

View File

@ -0,0 +1,43 @@
import { sql } from "~/db/sql";
import type { TournamentMatchGameResult, User } from "~/db/types";
import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator";
import { parseDBArray } from "~/utils/sql";
const stm = sql.prepare(/* sql */ `
select
"TournamentMatchGameResult"."id",
"TournamentMatchGameResult"."winnerTeamId",
"TournamentMatchGameResult"."stageId",
"TournamentMatchGameResult"."mode",
"TournamentMatchGameResult"."source",
"TournamentMatchGameResult"."createdAt",
json_group_array("TournamentMatchGameResultParticipant"."userId") as "participantIds"
from "TournamentMatchGameResult"
left join "TournamentMatchGameResultParticipant"
on "TournamentMatchGameResultParticipant"."matchGameResultId" = "TournamentMatchGameResult"."id"
where "TournamentMatchGameResult"."matchId" = @matchId
group by "TournamentMatchGameResult"."id"
order by "TournamentMatchGameResult"."number" asc
`);
interface FindResultsByMatchIdResult {
id: TournamentMatchGameResult["id"];
winnerTeamId: TournamentMatchGameResult["winnerTeamId"];
stageId: TournamentMatchGameResult["stageId"];
mode: TournamentMatchGameResult["mode"];
participantIds: Array<User["id"]>;
source: TournamentMaplistSource;
createdAt: TournamentMatchGameResult["createdAt"];
}
export function findResultsByMatchId(
matchId: number
): Array<FindResultsByMatchIdResult> {
const rows = stm.all({ matchId });
return rows.map((row) => ({
...row,
source: isNaN(row.source) ? row.source : Number(row.source),
participantIds: parseDBArray(row.participantIds),
}));
}

View File

@ -0,0 +1,16 @@
import { sql } from "~/db/sql";
import type { TournamentMatchGameResult } from "~/db/types";
const stm = sql.prepare(/* sql */ `
insert into "TournamentMatchGameResult"
("matchId", "stageId", "mode", "winnerTeamId", "reporterId", "number", "source")
values
(@matchId, @stageId, @mode, @winnerTeamId, @reporterId, @number, @source)
returning *
`);
export function insertTournamentMatchGameResult(
args: Omit<TournamentMatchGameResult, "id" | "createdAt">
) {
return stm.get(args) as TournamentMatchGameResult;
}

View File

@ -0,0 +1,15 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/* sql */ `
insert into "TournamentMatchGameResultParticipant"
("matchGameResultId", "userId")
values
(@matchGameResultId, @userId)
`);
export function insertTournamentMatchGameResultParticipant(args: {
matchGameResultId: number;
userId: number;
}) {
stm.run(args);
}

View File

@ -0,0 +1,11 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/* sql */ `
update TournamentMatch
set bestOf = @bestOf
where id = @id
`);
export function setBestOf({ id, bestOf }: { id: number; bestOf: 3 | 5 | 7 }) {
stm.run({ id, bestOf });
}

View File

@ -0,0 +1,30 @@
import type { LoaderArgs } from "@remix-run/node";
import { eventStream } from "remix-utils";
import { emitter } from "../core/emitters.server";
import { bracketSubscriptionKey } from "../tournament-bracket-utils";
import { tournamentIdFromParams } from "~/features/tournament";
export const loader = ({ request, params }: LoaderArgs) => {
const tournamentId = tournamentIdFromParams(params);
return eventStream(request.signal, (send) => {
const handler = (args: {
matchId: number;
scores: [number, number];
isOver: boolean;
}) => {
send({
event: bracketSubscriptionKey(tournamentId),
data: `${args.matchId}-${args.scores[0]}-${args.scores[1]}-${String(
args.isOver
)}`,
});
};
emitter.addListener(bracketSubscriptionKey(tournamentId), handler);
return () => {
emitter.removeListener(bracketSubscriptionKey(tournamentId), handler);
};
});
};

View File

@ -0,0 +1,260 @@
import type {
ActionFunction,
LinksFunction,
LoaderArgs,
} from "@remix-run/node";
import {
Form,
useLoaderData,
useNavigate,
useOutletContext,
useRevalidator,
} from "@remix-run/react";
import * as React from "react";
import bracketViewerStyles from "../brackets-viewer.css";
import bracketStyles from "../tournament-bracket.css";
import { findTeamsByTournamentId } from "../../tournament/queries/findTeamsByTournamentId.server";
import { Alert } from "~/components/Alert";
import { SubmitButton } from "~/components/SubmitButton";
import { getTournamentManager } from "../core/brackets-manager";
import hasTournamentStarted from "../../tournament/queries/hasTournamentStarted.server";
import { findByIdentifier } from "../../tournament/queries/findByIdentifier.server";
import { notFoundIfFalsy, validate } from "~/utils/remix";
import {
tournamentBracketsSubscribePage,
tournamentMatchPage,
} from "~/utils/urls";
import type { TournamentLoaderData } from "../../tournament/routes/to.$id";
import { resolveBestOfs } from "../core/bestOf.server";
import { findAllMatchesByTournamentId } from "../queries/findAllMatchesByTournamentId.server";
import { setBestOf } from "../queries/setBestOf.server";
import { canAdminTournament } from "~/permissions";
import { requireUser, useUser } from "~/modules/auth";
import { tournamentIdFromParams } from "~/features/tournament";
import {
bracketSubscriptionKey,
fillWithNullTillPowerOfTwo,
resolveTournamentStageName,
resolveTournamentStageSettings,
resolveTournamentStageType,
} from "../tournament-bracket-utils";
import { sql } from "~/db/sql";
import { useEventSource } from "remix-utils";
import { Status } from "~/db/types";
import { checkInHasStarted, teamHasCheckedIn } from "~/features/tournament";
export const links: LinksFunction = () => {
return [
{
rel: "stylesheet",
href: "https://cdn.jsdelivr.net/npm/brackets-viewer@latest/dist/brackets-viewer.min.css",
},
{
rel: "stylesheet",
href: bracketViewerStyles,
},
{
rel: "stylesheet",
href: bracketStyles,
},
];
};
export const action: ActionFunction = async ({ params, request }) => {
const user = await requireUser(request);
const manager = getTournamentManager("SQL");
const tournamentId = tournamentIdFromParams(params);
const tournament = notFoundIfFalsy(findByIdentifier(tournamentId));
const hasStarted = hasTournamentStarted(tournamentId);
validate(canAdminTournament({ user, event: tournament }));
validate(!hasStarted);
let teams = findTeamsByTournamentId(tournamentId);
if (checkInHasStarted(tournament)) {
teams = teams.filter(teamHasCheckedIn);
}
validate(teams.length >= 2, "Not enough teams registered");
sql.transaction(() => {
manager.create({
tournamentId,
name: resolveTournamentStageName(tournament.format),
type: resolveTournamentStageType(tournament.format),
seeding: fillWithNullTillPowerOfTwo(teams.map((team) => team.name)),
settings: resolveTournamentStageSettings(tournament.format),
});
const bestOfs = resolveBestOfs(findAllMatchesByTournamentId(tournamentId));
for (const [bestOf, id] of bestOfs) {
setBestOf({ bestOf, id });
}
})();
return null;
};
export const loader = ({ params }: LoaderArgs) => {
const tournamentId = tournamentIdFromParams(params);
const tournament = notFoundIfFalsy(findByIdentifier(tournamentId));
const hasStarted = hasTournamentStarted(tournamentId);
const manager = getTournamentManager(hasStarted ? "SQL" : "IN_MEMORY");
let teams = findTeamsByTournamentId(tournamentId);
if (checkInHasStarted(tournament)) {
teams = teams.filter(teamHasCheckedIn);
}
if (!hasStarted && teams.length >= 2) {
manager.create({
tournamentId,
name: resolveTournamentStageName(tournament.format),
type: resolveTournamentStageType(tournament.format),
seeding: fillWithNullTillPowerOfTwo(teams.map((team) => team.name)),
settings: resolveTournamentStageSettings(tournament.format),
});
}
// TODO: use get.stageData
const data = manager.get.tournamentData(tournamentId);
return {
bracket: data,
hasStarted,
};
};
export default function TournamentBracketsPage() {
const user = useUser();
const data = useLoaderData<typeof loader>();
const ref = React.useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const lessThanTwoTeamsRegistered = parentRouteData.teams.length < 2;
React.useEffect(() => {
if (lessThanTwoTeamsRegistered) return;
// matches aren't generated before tournament starts
if (data.hasStarted) {
// @ts-expect-error - brackets-viewer is not typed
window.bracketsViewer.onMatchClicked = (match) => {
// can't view match page of a bye
if (match.opponent1 === null || match.opponent2 === null) {
return;
}
navigate(
tournamentMatchPage({
eventId: parentRouteData.event.id,
matchId: match.id,
})
);
};
}
// @ts-expect-error - brackets-viewer is not typed
window.bracketsViewer.render({
stages: data.bracket.stage,
matches: data.bracket.match,
matchGames: data.bracket.match_game,
participants: data.bracket.participant,
});
const element = ref.current;
return () => {
if (!element) return;
element.innerHTML = "";
};
}, [
data.bracket,
navigate,
parentRouteData.event.id,
data.hasStarted,
lessThanTwoTeamsRegistered,
]);
// TODO: show floating prompt if active match
return (
<div>
<AutoRefresher />
{!data.hasStarted && !lessThanTwoTeamsRegistered ? (
<Form method="post" className="stack items-center">
{!canAdminTournament({ user, event: parentRouteData.event }) ? (
<Alert variation="INFO" alertClassName="w-max">
This bracket is a preview and subject to change
</Alert>
) : (
<Alert
variation="INFO"
alertClassName="tournament-bracket__start-bracket-alert"
textClassName="stack horizontal md items-center"
>
When everything looks good, finalize the bracket to start the
tournament{" "}
<SubmitButton variant="outlined" size="tiny">
Finalize
</SubmitButton>
</Alert>
)}
</Form>
) : null}
<div className="brackets-viewer" ref={ref}></div>
{lessThanTwoTeamsRegistered ? (
<div className="text-center text-lg font-semi-bold text-lighter">
Bracket will be shown here when at least 2 teams have registered
</div>
) : null}
</div>
);
}
// TODO: don't render this guy if tournament is over
function AutoRefresher() {
useAutoRefresh();
return null;
}
function useAutoRefresh() {
const { revalidate } = useRevalidator();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const lastEvent = useEventSource(
tournamentBracketsSubscribePage(parentRouteData.event.id),
{
event: bracketSubscriptionKey(parentRouteData.event.id),
}
);
React.useEffect(() => {
if (!lastEvent) return;
const [matchIdRaw, scoreOneRaw, scoreTwoRaw, isOverRaw] =
lastEvent.split("-");
const matchId = Number(matchIdRaw);
const scoreOne = Number(scoreOneRaw);
const scoreTwo = Number(scoreTwoRaw);
const isOver = isOverRaw === "true";
if (isOver) {
// bracketsViewer.updateMatch can't advance bracket
// so we revalidate loader when the match is over
revalidate();
} else {
// @ts-expect-error - brackets-viewer is not typed
window.bracketsViewer.updateMatch({
id: matchId,
opponent1: {
score: scoreOne,
},
opponent2: {
score: scoreTwo,
},
status: Status.Running,
});
}
}, [lastEvent, revalidate]);
}

View File

@ -0,0 +1,28 @@
import type { LoaderArgs } from "@remix-run/node";
import { eventStream } from "remix-utils";
import { emitter } from "../core/emitters.server";
import {
matchIdFromParams,
matchSubscriptionKey,
} from "../tournament-bracket-utils";
import { getUserId } from "~/modules/auth/user.server";
export const loader = async ({ request, params }: LoaderArgs) => {
const loggedInUser = await getUserId(request);
const matchId = matchIdFromParams(params);
return eventStream(request.signal, (send) => {
const handler = (args: { eventId: string; userId: number }) => {
// small optimization not to send the event
// if the user is the one who triggered the event
if (args.userId === loggedInUser?.id) return;
send({ event: matchSubscriptionKey(matchId), data: args.eventId });
};
emitter.addListener(matchSubscriptionKey(matchId), handler);
return () => {
emitter.removeListener(matchSubscriptionKey(matchId), handler);
};
});
};

View File

@ -0,0 +1,481 @@
import type {
ActionFunction,
LinksFunction,
LoaderArgs,
} from "@remix-run/node";
import { findMatchById } from "../queries/findMatchById.server";
import {
useLoaderData,
useOutletContext,
useRevalidator,
} from "@remix-run/react";
import { createTournamentMapList } from "~/modules/tournament-map-list-generator";
import { notFoundIfFalsy, parseRequestFormData, validate } from "~/utils/remix";
import { MapPool } from "~/modules/map-pool-serializer";
import { ScoreReporter } from "../components/ScoreReporter";
import { LinkButton } from "~/components/Button";
import { ArrowLongLeftIcon } from "~/components/icons/ArrowLongLeft";
import {
tournamentBracketsPage,
tournamentMatchSubscribePage,
} from "~/utils/urls";
import invariant from "tiny-invariant";
import { canAdminTournament, canReportTournamentScore } from "~/permissions";
import { requireUser, useUser } from "~/modules/auth";
import { getTournamentManager } from "../core/brackets-manager";
import { assertUnreachable } from "~/utils/types";
import { insertTournamentMatchGameResult } from "../queries/insertTournamentMatchGameResult.server";
import type { ModeShort, StageId } from "~/modules/in-game-lists";
import { findResultsByMatchId } from "../queries/findResultsByMatchId.server";
import { deleteTournamentMatchGameResultById } from "../queries/deleteTournamentMatchGameResultById.server";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import { findByIdentifier } from "../../tournament/queries/findByIdentifier.server";
import { findTeamsByTournamentId } from "../../tournament/queries/findTeamsByTournamentId.server";
import {
bracketSubscriptionKey,
checkSourceIsValid,
matchIdFromParams,
matchSubscriptionKey,
} from "../tournament-bracket-utils";
import { matchSchema } from "../tournament-bracket-schemas.server";
import {
modesIncluded,
tournamentIdFromParams,
type TournamentLoaderData,
} from "~/features/tournament";
import { insertTournamentMatchGameResultParticipant } from "../queries/insertTournamentMatchGameResultParticipant.server";
import bracketStyles from "../tournament-bracket.css";
import { sql } from "~/db/sql";
import { nanoid } from "nanoid";
import { emitter } from "../core/emitters.server";
import { useEventSource } from "remix-utils";
import * as React from "react";
export const links: LinksFunction = () => [
{
rel: "stylesheet",
href: bracketStyles,
},
];
export const action: ActionFunction = async ({ params, request }) => {
const user = await requireUser(request);
const matchId = matchIdFromParams(params);
const match = notFoundIfFalsy(findMatchById(matchId));
const data = await parseRequestFormData({
request,
schema: matchSchema,
});
const tournamentId = tournamentIdFromParams(params);
const event = notFoundIfFalsy(findByIdentifier(tournamentId));
const validateCanReportScore = () => {
const teams = findTeamsByTournamentId(tournamentId);
const ownedTeamId = teams.find((team) =>
team.members.some(
(member) => member.userId === user?.id && member.isOwner
)
)?.id;
validate(
canReportTournamentScore({
event,
match,
ownedTeamId,
user,
}),
"Unauthorized",
401
);
};
const manager = getTournamentManager("SQL");
const scores: [number, number] = [
match.opponentOne?.score ?? 0,
match.opponentTwo?.score ?? 0,
];
switch (data._action) {
case "REPORT_SCORE": {
validateCanReportScore();
validate(
match.opponentOne?.id === data.winnerTeamId ||
match.opponentTwo?.id === data.winnerTeamId,
"Winner team id is invalid"
);
validate(
checkSourceIsValid({ source: data.source, match }),
"Source is invalid"
);
// they are trying to report score that was already reported
// assume that it was already reported and make their page refresh
if (data.position !== scores[0] + scores[1]) {
return null;
}
const scoreToIncrement = () => {
if (data.winnerTeamId === match.opponentOne?.id) return 0;
if (data.winnerTeamId === match.opponentTwo?.id) return 1;
validate(false, "Winner team id is invalid");
};
scores[scoreToIncrement()]++;
sql.transaction(() => {
manager.update.match({
id: match.id,
opponent1: {
score: scores[0],
result:
scores[0] === Math.ceil(match.bestOf / 2) ? "win" : undefined,
},
opponent2: {
score: scores[1],
result:
scores[1] === Math.ceil(match.bestOf / 2) ? "win" : undefined,
},
});
const result = insertTournamentMatchGameResult({
matchId: match.id,
mode: data.mode as ModeShort,
stageId: data.stageId as StageId,
reporterId: user.id,
winnerTeamId: data.winnerTeamId,
number: data.position + 1,
source: data.source,
});
for (const userId of data.playerIds) {
insertTournamentMatchGameResultParticipant({
matchGameResultId: result.id,
userId,
});
}
})();
break;
}
case "UNDO_REPORT_SCORE": {
validateCanReportScore();
// they are trying to remove score from the past
if (data.position !== scores[0] + scores[1] - 1) {
return null;
}
const results = findResultsByMatchId(matchId);
const lastResult = results[results.length - 1];
invariant(lastResult, "Last result is missing");
const shouldReset = results.length === 1;
if (lastResult.winnerTeamId === match.opponentOne?.id) {
scores[0]--;
} else {
scores[1]--;
}
sql.transaction(() => {
deleteTournamentMatchGameResultById(lastResult.id);
manager.update.match({
id: match.id,
opponent1: {
score: shouldReset ? undefined : scores[0],
},
opponent2: {
score: shouldReset ? undefined : scores[1],
},
});
if (shouldReset) {
manager.reset.matchResults(match.id);
}
})();
break;
}
// TODO: bug where you can reopen losers finals after winners finals
case "REOPEN_MATCH": {
const scoreOne = match.opponentOne?.score ?? 0;
const scoreTwo = match.opponentTwo?.score ?? 0;
invariant(typeof scoreOne === "number", "Score one is missing");
invariant(typeof scoreTwo === "number", "Score two is missing");
invariant(scoreOne !== scoreTwo, "Scores are equal");
validate(canAdminTournament({ event, user }));
const results = findResultsByMatchId(matchId);
const lastResult = results[results.length - 1];
invariant(lastResult, "Last result is missing");
if (scoreOne > scoreTwo) {
scores[0]--;
} else {
scores[1]--;
}
try {
sql.transaction(() => {
deleteTournamentMatchGameResultById(lastResult.id);
manager.update.match({
id: match.id,
opponent1: {
score: scores[0],
result: undefined,
},
opponent2: {
score: scores[1],
result: undefined,
},
});
})();
} catch (err) {
if (!(err instanceof Error)) throw err;
if (err.message.includes("locked")) {
return { error: "locked" };
}
throw err;
}
break;
}
default: {
assertUnreachable(data);
}
}
emitter.emit(matchSubscriptionKey(match.id), {
eventId: nanoid(),
userId: user.id,
});
emitter.emit(bracketSubscriptionKey(event.id), {
matchId: match.id,
scores,
isOver:
scores[0] === Math.ceil(match.bestOf / 2) ||
scores[1] === Math.ceil(match.bestOf / 2),
});
return null;
};
export type TournamentMatchLoaderData = typeof loader;
export const loader = ({ params }: LoaderArgs) => {
const matchId = matchIdFromParams(params);
const match = notFoundIfFalsy(findMatchById(matchId));
return {
match,
results: findResultsByMatchId(matchId),
};
};
export default function TournamentMatchPage() {
const parentRouteData = useOutletContext<TournamentLoaderData>();
const data = useLoaderData<typeof loader>();
const matchHasTwoTeams = Boolean(
data.match.opponentOne?.id && data.match.opponentTwo?.id
);
const matchIsOver =
data.match.opponentOne?.result === "win" ||
data.match.opponentTwo?.result === "win";
return (
<div className="stack lg">
{!matchIsOver ? <AutoRefresher /> : null}
<div className="flex horizontal justify-between items-center">
{/* TODO: better title */}
<h2 className="text-lighter text-lg">Match #{data.match.id}</h2>
<LinkButton
to={tournamentBracketsPage(parentRouteData.event.id)}
variant="outlined"
size="tiny"
className="w-max"
icon={<ArrowLongLeftIcon />}
>
Back to bracket
</LinkButton>
</div>
{!matchHasTwoTeams ? (
<div className="text-lg text-lighter font-semi-bold text-center">
Waiting for teams
</div>
) : null}
{matchIsOver ? <ResultsSection /> : null}
{!matchIsOver &&
typeof data.match.opponentOne?.id === "number" &&
typeof data.match.opponentTwo?.id === "number" ? (
<MapListSection
teams={[data.match.opponentOne.id, data.match.opponentTwo.id]}
/>
) : null}
</div>
);
}
function AutoRefresher() {
useAutoRefresh();
return null;
}
function useAutoRefresh() {
const { revalidate } = useRevalidator();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const data = useLoaderData<typeof loader>();
const lastEventId = useEventSource(
tournamentMatchSubscribePage({
eventId: parentRouteData.event.id,
matchId: data.match.id,
}),
{
event: matchSubscriptionKey(data.match.id),
}
);
React.useEffect(() => {
if (lastEventId) {
revalidate();
}
}, [lastEventId, revalidate]);
}
function MapListSection({ teams }: { teams: [id: number, id: number] }) {
const user = useUser();
const data = useLoaderData<typeof loader>();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const teamOne = parentRouteData.teams.find((team) => team.id === teams[0]);
const teamTwo = parentRouteData.teams.find((team) => team.id === teams[1]);
if (!teamOne || !teamTwo) return null;
const teamOneMaps = new MapPool(teamOne.mapPool ?? []);
const teamTwoMaps = new MapPool(teamTwo.mapPool ?? []);
let maps;
try {
maps = createTournamentMapList({
bestOf: data.match.bestOf,
seed: String(data.match.id),
modesIncluded: modesIncluded(parentRouteData.event),
tiebreakerMaps: new MapPool(parentRouteData.tieBreakerMapPool),
teams: [
{
id: teams[0],
maps: teamOneMaps,
},
{
id: teams[1],
maps: teamTwoMaps,
},
],
});
} catch (e) {
console.error(
"Failed to create map list. Falling back to default maps.",
e
);
maps = createTournamentMapList({
bestOf: data.match.bestOf,
seed: String(data.match.id),
modesIncluded: modesIncluded(parentRouteData.event),
tiebreakerMaps: new MapPool(parentRouteData.tieBreakerMapPool),
teams: [
{
id: -1,
maps: new MapPool([]),
},
{
id: -2,
maps: new MapPool([]),
},
],
});
}
const scoreSum =
(data.match.opponentOne?.score ?? 0) + (data.match.opponentTwo?.score ?? 0);
const currentStageWithMode = maps[scoreSum];
invariant(currentStageWithMode, "No map found for this score");
const isMemberOfATeam =
teamOne.members.some((m) => m.userId === user?.id) ||
teamTwo.members.some((m) => m.userId === user?.id);
return (
<ScoreReporter
currentStageWithMode={currentStageWithMode}
teams={[teamOne, teamTwo]}
modes={maps.map((map) => map.mode)}
type={
canReportTournamentScore({
event: parentRouteData.event,
match: data.match,
ownedTeamId: parentRouteData.ownedTeamId,
user,
})
? "EDIT"
: isMemberOfATeam
? "MEMBER"
: "OTHER"
}
/>
);
}
function ResultsSection() {
const data = useLoaderData<typeof loader>();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const [selectedResultIndex, setSelectedResultIndex] = useSearchParamState({
defaultValue: data.results.length - 1,
name: "result",
revive: (value) => {
const maybeIndex = Number(value);
if (!Number.isInteger(maybeIndex)) return;
if (maybeIndex < 0 || maybeIndex >= data.results.length) return;
return maybeIndex;
},
});
const result = data.results[selectedResultIndex];
invariant(result, "Result is missing");
const teamOne = parentRouteData.teams.find(
(team) => team.id === data.match.opponentOne?.id
);
const teamTwo = parentRouteData.teams.find(
(team) => team.id === data.match.opponentTwo?.id
);
if (!teamOne || !teamTwo) {
throw new Error("Team is missing");
}
return (
<ScoreReporter
currentStageWithMode={result}
teams={[teamOne, teamTwo]}
modes={data.results.map((result) => result.mode)}
selectedResultIndex={selectedResultIndex}
setSelectedResultIndex={setSelectedResultIndex}
result={result}
type="OTHER"
/>
);
}

View File

@ -0,0 +1,36 @@
import { z } from "zod";
import { id, modeShort, safeJSONParse, stageId } from "~/utils/zod";
import { TOURNAMENT } from "../tournament/tournament-constants";
const reportedMatchPlayerIds = z.preprocess(
safeJSONParse,
z.array(id).length(TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL * 2)
);
const reportedMatchPosition = z.preprocess(
Number,
z
.number()
.int()
.min(0)
.max(Math.max(...TOURNAMENT.AVAILABLE_BEST_OF) - 1)
);
export const matchSchema = z.union([
z.object({
_action: z.literal("REPORT_SCORE"),
winnerTeamId: id,
position: reportedMatchPosition,
playerIds: reportedMatchPlayerIds,
stageId,
mode: modeShort,
source: z.string(),
}),
z.object({
_action: z.literal("UNDO_REPORT_SCORE"),
position: reportedMatchPosition,
}),
z.object({
_action: z.literal("REOPEN_MATCH"),
}),
]);

View File

@ -0,0 +1,68 @@
import { suite } from "uvu";
import * as assert from "uvu/assert";
import {
fillWithNullTillPowerOfTwo,
mapCountPlayedInSetWithCertainty,
} from "./tournament-bracket-utils";
const MapCountPlayedInSetWithCertainty = suite(
"mapCountPlayedInSetWithCertainty()"
);
const FillWithNullTillPowerOfTwo = suite("fillWithNullTillPowerOfTwo()");
const mapCountParamsToResult: {
bestOf: number;
scores: [number, number];
expected: number;
}[] = [
{ bestOf: 3, scores: [0, 0], expected: 2 },
{ bestOf: 3, scores: [1, 0], expected: 2 },
{ bestOf: 3, scores: [1, 1], expected: 3 },
{ bestOf: 5, scores: [0, 0], expected: 3 },
{ bestOf: 5, scores: [1, 0], expected: 3 },
{ bestOf: 5, scores: [2, 0], expected: 3 },
{ bestOf: 5, scores: [2, 1], expected: 4 },
{ bestOf: 7, scores: [0, 0], expected: 4 },
{ bestOf: 7, scores: [2, 2], expected: 6 },
];
for (const { bestOf, scores, expected } of mapCountParamsToResult) {
MapCountPlayedInSetWithCertainty(
`bestOf=${bestOf}, scores=${scores.join(",")} -> ${expected}`,
() => {
assert.equal(
mapCountPlayedInSetWithCertainty({ bestOf, scores }),
expected
);
}
);
}
const powerOfTwoParamsToResults: [
amountOfTeams: number,
expectedNullCount: number
][] = [
[32, 0],
[16, 0],
[8, 0],
[31, 1],
[0, 0],
[17, 15],
];
for (const [amountOfTeams, expectedNullCount] of powerOfTwoParamsToResults) {
FillWithNullTillPowerOfTwo(
`amountOfTeams=${amountOfTeams} -> ${expectedNullCount}`,
() => {
assert.equal(
fillWithNullTillPowerOfTwo(Array(amountOfTeams).fill("team")).filter(
(x) => x === null
).length,
expectedNullCount
);
}
);
}
MapCountPlayedInSetWithCertainty.run();
FillWithNullTillPowerOfTwo.run();

View File

@ -0,0 +1,143 @@
import type { Stage } from "brackets-model";
import type {
TournamentFormat,
TournamentMatch,
TournamentStage,
} from "~/db/types";
import {
sourceTypes,
seededRandom,
} from "~/modules/tournament-map-list-generator";
import { assertUnreachable } from "~/utils/types";
import type { FindMatchById } from "../tournament-bracket/queries/findMatchById.server";
import type {
TournamentLoaderData,
TournamentLoaderTeam,
} from "~/features/tournament";
import type { Params } from "@remix-run/react";
import invariant from "tiny-invariant";
export function matchIdFromParams(params: Params<string>) {
const result = Number(params["mid"]);
invariant(!Number.isNaN(result), "mid is not a number");
return result;
}
const passNumbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
export function resolveRoomPass(matchId: TournamentMatch["id"]) {
let result = "";
for (let i = 0; i < 4; i++) {
const { shuffle } = seededRandom(`${matchId}-${i}`);
result += shuffle(passNumbers)[0];
}
return result;
}
export function resolveHostingTeam(
teams: [TournamentLoaderTeam, TournamentLoaderTeam]
) {
if (teams[0].prefersNotToHost && !teams[1].prefersNotToHost) return teams[1];
if (!teams[0].prefersNotToHost && teams[1].prefersNotToHost) return teams[0];
if (!teams[0].seed && !teams[1].seed) return teams[0];
if (!teams[0].seed) return teams[1];
if (!teams[1].seed) return teams[0];
if (teams[0].seed < teams[1].seed) return teams[0];
if (teams[1].seed < teams[0].seed) return teams[1];
console.error("resolveHostingTeam: unexpected default");
return teams[0];
}
export function resolveTournamentStageName(format: TournamentFormat) {
switch (format) {
case "SE":
case "DE":
return "Elimination stage";
default: {
assertUnreachable(format);
}
}
}
export function resolveTournamentStageType(
format: TournamentFormat
): TournamentStage["type"] {
switch (format) {
case "SE":
return "single_elimination";
case "DE":
return "double_elimination";
default: {
assertUnreachable(format);
}
}
}
export function resolveTournamentStageSettings(
format: TournamentFormat
): Stage["settings"] {
switch (format) {
case "SE":
return {};
case "DE":
return { grandFinal: "double" };
default: {
assertUnreachable(format);
}
}
}
export function mapCountPlayedInSetWithCertainty({
bestOf,
scores,
}: {
bestOf: number;
scores: [number, number];
}) {
const maxScore = Math.max(...scores);
const scoreSum = scores.reduce((acc, curr) => acc + curr, 0);
return scoreSum + (Math.ceil(bestOf / 2) - maxScore);
}
export function checkSourceIsValid({
source,
match,
}: {
source: string;
match: NonNullable<FindMatchById>;
}) {
if (sourceTypes.includes(source as any)) return true;
const asTeamId = Number(source);
if (match.opponentOne?.id === asTeamId) return true;
if (match.opponentTwo?.id === asTeamId) return true;
return false;
}
export function HACKY_resolvePoolCode(event: TournamentLoaderData["event"]) {
if (event.name.includes("In The Zone")) return "ITZ";
return "PICNIC";
}
export function bracketSubscriptionKey(tournamentId: number) {
return `BRACKET_CHANGED_${tournamentId}`;
}
export function matchSubscriptionKey(matchId: number) {
return `MATCH_CHANGED_${matchId}`;
}
export function fillWithNullTillPowerOfTwo<T>(arr: T[]) {
const nextPowerOfTwo = Math.pow(2, Math.ceil(Math.log2(arr.length)));
const nullsToAdd = nextPowerOfTwo - arr.length;
return [...arr, ...new Array(nullsToAdd).fill(null)];
}

View File

@ -0,0 +1,242 @@
.tournament-bracket__start-bracket-alert {
line-height: 1.4;
}
.tournament-bracket__infos {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-bottom: var(--s-4);
background: var(--bg-lighter);
border-end-end-radius: var(--rounded);
border-end-start-radius: var(--rounded);
color: var(--tournaments-text);
column-gap: var(--s-8);
font-size: var(--fonts-xs);
}
.tournament-bracket__infos__label {
margin-block-end: 0;
}
.tournament-bracket__infos__value > button {
font-size: var(--fonts-xxs);
}
.tournament-bracket__stage-banner {
display: flex;
width: 100%;
height: 10rem;
flex-direction: column;
justify-content: space-between;
background-image: var(--_tournament-bg-url);
background-position: center;
border-start-end-radius: var(--rounded);
border-start-start-radius: var(--rounded);
grid-area: img;
background-repeat: no-repeat;
background-size: cover;
}
.tournament-bracket__stage-banner__top-bar {
display: flex;
justify-content: space-between;
padding: var(--s-2);
/* TODO: add fallback from Firefox */
backdrop-filter: blur(5px);
background: rgb(0 0 0 / 25%);
border-start-end-radius: var(--rounded);
border-start-start-radius: var(--rounded);
}
.tournament-bracket__stage-banner__bottom-bar {
display: flex;
justify-content: flex-end;
padding: var(--s-2);
}
.tournament-bracket__stage-banner__undo-button {
border: 0;
background-color: var(--theme-error-semi-transparent);
color: var(--text);
font-size: var(--fonts-xxs);
padding-block: var(--s-1);
padding-inline: var(--s-2);
}
.tournament-bracket__stage-banner__top-bar__header {
display: flex;
align-items: center;
justify-content: center;
gap: var(--s-2);
}
.tournament-bracket__during-match-actions {
display: grid;
grid-template-areas:
"img"
"infos"
"rosters";
grid-template-columns: 1fr;
}
.tournament-bracket__during-match-actions__actions {
display: flex;
justify-content: center;
color: var(--theme-warning);
margin-block-start: var(--s-6);
}
.tournament-bracket__during-match-actions__amount-warning-paragraph {
display: flex;
align-items: center;
text-align: center;
font-size: var(--fonts-xs);
}
.tournament-bracket__during-match-actions__confirm-score-text {
font-size: var(--fonts-xs);
color: var(--text);
text-align: center;
}
.tournament-bracket__during-match-actions__rosters {
display: flex;
width: 100%;
flex-wrap: wrap;
justify-content: space-evenly;
row-gap: var(--s-4);
text-align: center;
}
.tournament-bracket__during-match-actions__radio-container {
display: flex;
align-items: center;
justify-content: center;
}
.tournament-bracket__during-match-actions__team-players {
display: flex;
width: 15rem;
flex-direction: column;
margin-top: var(--s-3);
gap: var(--s-2);
}
.tournament-bracket__during-match-actions__checkbox-name {
display: flex;
width: 100%;
align-items: center;
}
.tournament-bracket__during-match-actions__checkbox-name:not(
.disabled-opaque
):not(.presentational):hover {
border-radius: var(--rounded);
cursor: pointer;
outline: 2px solid var(--theme-transparent);
outline-offset: 2px;
}
.tournament-bracket__during-match-actions__checkbox {
width: 50% !important;
height: 1.5rem !important;
appearance: none;
background-color: var(--theme-transparent);
border-end-start-radius: var(--rounded);
border-start-start-radius: var(--rounded);
cursor: pointer;
}
.tournament-bracket__during-match-actions__checkbox:checked {
background-color: var(--theme);
}
.tournament-bracket__during-match-actions__checkbox::after {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
color: var(--text-lighter);
content: "Didn't play";
font-size: var(--fonts-xxs);
font-weight: var(--bold);
letter-spacing: var(--sparse);
padding-block-end: 1px;
}
.tournament-bracket__during-match-actions__checkbox:checked::after {
color: var(--button-text);
content: "Played";
}
.tournament-bracket__during-match-actions__player-name {
display: flex;
width: 50%;
height: 1.5rem;
align-items: center;
justify-content: center;
margin: 0;
background-color: var(--bg);
border-end-end-radius: var(--rounded);
border-start-end-radius: var(--rounded);
overflow-x: hidden;
text-overflow: ellipsis;
user-select: none;
cursor: pointer;
}
.tournament-bracket__mode-progress {
display: flex;
margin-bottom: var(--s-4);
gap: var(--s-4);
justify-content: center;
}
.tournament-bracket__mode-progress__image {
background-color: var(--bg-lighter);
border-radius: 100%;
padding: var(--s-2-5);
outline: 2px solid var(--bg-lightest);
}
.tournament-bracket__mode-progress__image__notable {
background-color: var(--bg-lightest);
border: none;
outline: 2px solid var(--bg-lightest);
}
.tournament-bracket__mode-progress__image__team-one-win {
outline: 2px solid var(--theme);
}
.tournament-bracket__mode-progress__image__team-two-win {
outline: 2px solid var(--theme-secondary);
}
.tournament-bracket__mode-progress__image__selected {
background-color: var(--bg-lighter);
}
.tournament-bracket__team-one-dot {
border-radius: 100%;
background-color: var(--theme);
width: 8px;
height: 8px;
}
.tournament-bracket__team-two-dot {
border-radius: 100%;
background-color: var(--theme-secondary);
width: 8px;
height: 8px;
}
@media screen and (min-width: 480px) {
.tournament-bracket__infos {
flex-direction: row;
}
}

View File

@ -0,0 +1,11 @@
export { TOURNAMENT } from "./tournament-constants";
export type {
TournamentLoaderTeam,
TournamentLoaderData,
} from "./routes/to.$id";
export {
tournamentIdFromParams,
modesIncluded,
checkInHasStarted,
teamHasCheckedIn,
} from "./tournament-utils";

View File

@ -0,0 +1,30 @@
import { sql } from "~/db/sql";
import type { User, TournamentTeam } from "~/db/types";
const stm = sql.prepare(/* sql */ `
update TournamentTeamMember
set "isOwner" = @isOwner
where
"tournamentTeamId" = @tournamentTeamId and
"userId" = @userId
`);
export const changeTeamOwner = sql.transaction(
(args: {
tournamentTeamId: TournamentTeam["id"];
oldCaptainId: User["id"];
newCaptainId: User["id"];
}) => {
stm.run({
tournamentTeamId: args.tournamentTeamId,
userId: args.oldCaptainId,
isOwner: 0,
});
stm.run({
tournamentTeamId: args.tournamentTeamId,
userId: args.newCaptainId,
isOwner: 1,
});
}
);

View File

@ -0,0 +1,12 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/* sql */ `
insert into "TournamentTeamCheckIn"
("tournamentTeamId", "checkedInAt")
values
(@tournamentTeamId, strftime('%s', 'now'))
`);
export function checkIn(tournamentTeamId: number) {
stm.run({ tournamentTeamId });
}

View File

@ -0,0 +1,10 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/* sql */ `
delete from "TournamentTeamCheckIn"
where "tournamentTeamId" = @tournamentTeamId
`);
export function checkOut(tournamentTeamId: number) {
stm.run({ tournamentTeamId });
}

View File

@ -5,13 +5,15 @@ import { INVITE_CODE_LENGTH } from "~/constants";
const createTeamStm = sql.prepare(/*sql*/ `
insert into "TournamentTeam" (
"calendarEventId",
"tournamentId",
"inviteCode",
"name"
"name",
"prefersNotToHost"
) values (
@calendarEventId,
@tournamentId,
@inviteCode,
@name
@name,
@prefersNotToHost
) returning *
`);
@ -29,18 +31,21 @@ const createMemberStm = sql.prepare(/*sql*/ `
export const createTeam = sql.transaction(
({
calendarEventId,
tournamentId,
name,
ownerId,
prefersNotToHost,
}: {
calendarEventId: TournamentTeam["calendarEventId"];
tournamentId: TournamentTeam["tournamentId"];
name: TournamentTeam["name"];
ownerId: User["id"];
prefersNotToHost: TournamentTeam["prefersNotToHost"];
}) => {
const team = createTeamStm.get({
calendarEventId,
tournamentId,
name,
inviteCode: nanoid(INVITE_CODE_LENGTH),
prefersNotToHost,
}) as TournamentTeam;
createMemberStm.run({ tournamentTeamId: team.id, userId: ownerId });

View File

@ -0,0 +1,10 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/*sql*/ `
delete from "TournamentTeam"
where "id" = @tournamentTeamId
`);
export function deleteTeam(tournamentTeamId: number) {
stm.run({ tournamentTeamId });
}

View File

@ -1,53 +1,50 @@
import { sql } from "~/db/sql";
import type { CalendarEvent, CalendarEventDate, User } from "~/db/types";
import type {
CalendarEvent,
CalendarEventDate,
Tournament,
User,
} from "~/db/types";
// TODO: doesn't work if many start times
const stm = sql.prepare(/*sql*/ `
select
select
"Tournament"."id",
"Tournament"."mapPickingStyle",
"Tournament"."format",
"Tournament"."showMapListGenerator",
"CalendarEvent"."id" as "eventId",
"CalendarEvent"."name",
"CalendarEvent"."description",
"CalendarEvent"."id",
"CalendarEvent"."bracketUrl",
"CalendarEvent"."authorId",
"CalendarEvent"."isBeforeStart",
"CalendarEvent"."toToolsMode",
"CalendarEventDate"."startTime",
"User"."discordName",
"User"."discordDiscriminator",
"User"."discordId"
from "CalendarEvent"
from "Tournament"
left join "CalendarEvent" on "Tournament"."id" = "CalendarEvent"."tournamentId"
left join "User" on "CalendarEvent"."authorId" = "User"."id"
left join "CalendarEventDate" on "CalendarEvent"."id" = "CalendarEventDate"."eventId"
where
(
"CalendarEvent"."id" = @identifier
or "CalendarEvent"."customUrl" = @identifier
)
and "CalendarEvent"."toToolsEnabled" = 1
where "Tournament"."id" = @identifier
group by "CalendarEvent"."id"
`);
type FindByIdentifierRow =
| (Pick<
CalendarEvent,
| "bracketUrl"
| "id"
| "name"
| "description"
| "authorId"
| "isBeforeStart"
| "toToolsMode"
> &
| (Pick<CalendarEvent, "bracketUrl" | "name" | "description" | "authorId"> &
Pick<
Tournament,
"id" | "format" | "mapPickingStyle" | "showMapListGenerator"
> &
Pick<User, "discordId" | "discordName" | "discordDiscriminator"> &
Pick<CalendarEventDate, "startTime">)
| null;
Pick<CalendarEventDate, "startTime">) & { eventId: CalendarEvent["id"] };
export function findByIdentifier(identifier: string | number) {
const row = stm.get({ identifier }) as FindByIdentifierRow;
const rows = stm.all({ identifier }) as FindByIdentifierRow[];
if (rows.length === 0) return null;
if (!row) return null;
const tournament = { ...rows[0], startTime: resolveEarliestStartTime(rows) };
const { discordId, discordName, discordDiscriminator, ...rest } = row;
const { discordId, discordName, discordDiscriminator, ...rest } = tournament;
return {
...rest,
@ -58,3 +55,9 @@ export function findByIdentifier(identifier: string | number) {
},
};
}
function resolveEarliestStartTime(
rows: Pick<CalendarEventDate, "startTime">[]
) {
return Math.min(...rows.map((row) => row.startTime));
}

View File

@ -1,36 +1,38 @@
import { sql } from "~/db/sql";
import type { TournamentTeam } from "~/db/types";
import type { TournamentTeam, TournamentTeamCheckIn } from "~/db/types";
const stm = sql.prepare(/*sql*/ `
select
"TournamentTeam"."id",
"TournamentTeam"."name",
"TournamentTeam"."checkedInAt",
"TournamentTeamCheckIn"."checkedInAt",
"TournamentTeam"."inviteCode"
from
"TournamentTeam"
left join "TournamentTeamCheckIn" on
"TournamentTeamCheckIn"."tournamentTeamId" = "TournamentTeam"."id"
left join "TournamentTeamMember" on
"TournamentTeamMember"."tournamentTeamId" = "TournamentTeam"."id"
and "TournamentTeamMember"."isOwner" = 1
where
"TournamentTeam"."calendarEventId" = @calendarEventId
"TournamentTeam"."tournamentId" = @tournamentId
and "TournamentTeamMember"."userId" = @userId
`);
type FindOwnTeam = Pick<
TournamentTeam,
"id" | "name" | "checkedInAt" | "inviteCode"
> | null;
type FindOwnTeam =
| (Pick<TournamentTeam, "id" | "name" | "inviteCode"> &
Pick<TournamentTeamCheckIn, "checkedInAt">)
| null;
export function findOwnTeam({
calendarEventId,
tournamentId,
userId,
}: {
calendarEventId: number;
tournamentId: number;
userId: number;
}) {
return stm.get({
calendarEventId,
tournamentId,
userId,
}) as FindOwnTeam;
}

View File

@ -3,7 +3,7 @@ import { sql } from "~/db/sql";
const stm = sql.prepare(/*sql */ `
select
"TournamentTeam"."id",
"TournamentTeam"."calendarEventId"
"TournamentTeam"."tournamentId"
from "TournamentTeam"
where "TournamentTeam"."inviteCode" = @inviteCode
`);
@ -11,6 +11,6 @@ const stm = sql.prepare(/*sql */ `
export function findByInviteCode(inviteCode: string) {
return stm.get({ inviteCode }) as {
id: number;
calendarEventId: number;
tournamentId: number;
} | null;
}

View File

@ -1,8 +1,9 @@
import { sql } from "~/db/sql";
import type {
CalendarEvent,
MapPoolMap,
Tournament,
TournamentTeam,
TournamentTeamCheckIn,
TournamentTeamMember,
UserWithPlusTier,
} from "~/db/types";
@ -13,6 +14,9 @@ const stm = sql.prepare(/*sql*/ `
select
"TournamentTeam"."id",
"TournamentTeam"."name",
"TournamentTeam"."seed",
"TournamentTeamCheckIn"."checkedInAt",
"TournamentTeam"."prefersNotToHost",
json_group_array(
json_object(
'userId',
@ -27,17 +31,20 @@ const stm = sql.prepare(/*sql*/ `
"User"."discordId",
'discordAvatar',
"User"."discordAvatar",
'inGameName',
"User"."inGameName",
'plusTier',
"PlusTier"."tier"
)
) as "members"
from
"TournamentTeam"
left join "TournamentTeamCheckIn" on "TournamentTeamCheckIn"."tournamentTeamId" = "TournamentTeam"."id"
left join "TournamentTeamMember" on "TournamentTeamMember"."tournamentTeamId" = "TournamentTeam"."id"
left join "User" on "User"."id" = "TournamentTeamMember"."userId"
left join "PlusTier" on "User"."id" = "PlusTier"."userId"
where
"TournamentTeam"."calendarEventId" = @calendarEventId
"TournamentTeam"."tournamentId" = @tournamentId
group by
"TournamentTeam"."id"
)
@ -57,12 +64,15 @@ const stm = sql.prepare(/*sql*/ `
group by
"TeamWithMembers"."id"
order by
"TeamWithMembers"."name" asc
"TeamWithMembers"."seed" asc
`);
export interface FindTeamsByEventIdItem {
export interface FindTeamsByTournamentIdItem {
id: TournamentTeam["id"];
name: TournamentTeam["name"];
seed: TournamentTeam["seed"];
checkedInAt: TournamentTeamCheckIn["checkedInAt"];
prefersNotToHost: TournamentTeam["prefersNotToHost"];
members: Array<
Pick<TournamentTeamMember, "userId" | "isOwner"> &
Pick<
@ -72,14 +82,15 @@ export interface FindTeamsByEventIdItem {
| "discordName"
| "plusTier"
| "discordDiscriminator"
| "inGameName"
>
>;
mapPool?: Array<Pick<MapPoolMap, "mode" | "stageId">>;
}
export type FindTeamsByEventId = Array<FindTeamsByEventIdItem>;
export type FindTeamsByTournamentId = Array<FindTeamsByTournamentIdItem>;
export function findTeamsByEventId(calendarEventId: CalendarEvent["id"]) {
const rows = stm.all({ calendarEventId });
export function findTeamsByTournamentId(tournamentId: Tournament["id"]) {
const rows = stm.all({ tournamentId });
return rows.map((row) => {
return {
@ -87,5 +98,5 @@ export function findTeamsByEventId(calendarEventId: CalendarEvent["id"]) {
members: parseDBJsonArray(row.members),
mapPool: parseDBJsonArray(row.mapPool),
};
}) as FindTeamsByEventId;
}) as FindTeamsByTournamentId;
}

View File

@ -0,0 +1,37 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/* sql */ `
select
"User"."id",
"User"."discordName",
"User"."discordDiscriminator"
from "TeamMember"
left join "User" on "User"."id" = "TeamMember"."userId"
where "TeamMember"."teamId" = @teamId
and "TeamMember"."userId" != @userId
union
select
"User"."id",
"User"."discordName",
"User"."discordDiscriminator"
from "TrustRelationship"
left join "User" on "User"."id" = "TrustRelationship"."trustGiverUserId"
where "TrustRelationship"."trustReceiverUserId" = @userId
`);
export interface TrustedPlayer {
id: number;
discordName: string;
discordDiscriminator: string;
}
export function findTrustedPlayers({
teamId,
userId,
}: {
teamId?: number;
userId: number;
}): Array<TrustedPlayer> {
if (!teamId) return [];
return stm.all({ teamId, userId });
}

View File

@ -0,0 +1,21 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/*sql */ `
insert into "TrustRelationship" (
"trustGiverUserId",
"trustReceiverUserId"
) values (
@trustGiverUserId,
@trustReceiverUserId
)
`);
export function giveTrust({
trustGiverUserId,
trustReceiverUserId,
}: {
trustGiverUserId: number;
trustReceiverUserId: number;
}) {
stm.run({ trustGiverUserId, trustReceiverUserId });
}

View File

@ -0,0 +1,12 @@
import { sql } from "~/db/sql";
import type { Tournament } from "~/db/types";
const stm = sql.prepare(/*sql*/ `
select 1
from "TournamentStage"
where "TournamentStage"."tournamentId" = @tournamentId
`);
export default function hasTournamentStarted(tournamentId: Tournament["id"]) {
return Boolean(stm.get({ tournamentId }));
}

View File

@ -1,4 +1,6 @@
import invariant from "tiny-invariant";
import { sql } from "~/db/sql";
import { checkOut } from "./checkOut.server";
const createTeamMemberStm = sql.prepare(/*sql*/ `
insert into "TournamentTeamMember" (
@ -21,17 +23,21 @@ const deleteMemberStm = sql.prepare(/*sql*/ `
and "userId" = @userId
`);
// TODO: divide this to different queries and compose in route
// TODO: if captain leaves don't delete but give captain to someone else
export const joinTeam = sql.transaction(
({
previousTeamId,
whatToDoWithPreviousTeam,
newTeamId,
userId,
checkOutTeam = false,
}: {
previousTeamId?: number;
whatToDoWithPreviousTeam?: "LEAVE" | "DELETE";
newTeamId: number;
userId: number;
checkOutTeam?: boolean;
}) => {
if (whatToDoWithPreviousTeam === "DELETE") {
deleteTeamStm.run({ tournamentTeamId: previousTeamId });
@ -39,6 +45,24 @@ export const joinTeam = sql.transaction(
deleteMemberStm.run({ tournamentTeamId: previousTeamId, userId });
}
if (checkOutTeam) {
invariant(
previousTeamId,
"previousTeamId is required when checking out team"
);
checkOut(previousTeamId);
}
createTeamMemberStm.run({ tournamentTeamId: newTeamId, userId });
}
);
export const leaveTeam = ({
teamId,
userId,
}: {
teamId: number;
userId: number;
}) => {
deleteMemberStm.run({ tournamentTeamId: teamId, userId });
};

View File

@ -0,0 +1,21 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/*sql*/ `
select
"User"."id",
max("XRankPlacement"."power") as "xPower"
from "User"
right join "SplatoonPlayer" on "SplatoonPlayer"."userId" = "User"."id"
left join "XRankPlacement" on "XRankPlacement"."playerId" = "SplatoonPlayer"."id"
where "User"."id" is not null
group by "User"."id"
`);
export const maxXPowers = () => {
const rows = stm.all() as { id: number; xPower: number }[];
return rows.reduce((acc, row) => {
acc[row.id] = row.xPower;
return acc;
}, {} as Record<number, number>);
};

View File

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

View File

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

View File

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

View File

@ -0,0 +1,26 @@
import { sql } from "~/db/sql";
const resetSeeds = sql.prepare(/*sql*/ `
update "TournamentTeam"
set "seed" = null
where "tournamentId" = @tournamentId
`);
const updateSeedStm = sql.prepare(/*sql*/ `
update "TournamentTeam"
set "seed" = @seed
where "id" = @teamId
`);
export const updateTeamSeeds = sql.transaction(
({ tournamentId, teamIds }: { tournamentId: number; teamIds: number[] }) => {
resetSeeds.run({ tournamentId });
for (const [i, teamId] of teamIds.entries()) {
updateSeedStm.run({
teamId,
seed: i + 1,
});
}
}
);

View File

@ -1,79 +1,337 @@
import type { LoaderArgs, ActionFunction } from "@remix-run/node";
import { useLoaderData, useSubmit } from "@remix-run/react";
import type { ActionFunction } from "@remix-run/node";
import { useFetcher, useOutletContext, useSubmit } from "@remix-run/react";
import * as React from "react";
import invariant from "tiny-invariant";
import { z } from "zod";
import { Button } from "~/components/Button";
import { FormMessage } from "~/components/FormMessage";
import { Button, LinkButton } from "~/components/Button";
import { Toggle } from "~/components/Toggle";
import { useTranslation } from "~/hooks/useTranslation";
import { canAdminCalendarTOTools } from "~/permissions";
import { canAdminTournament, isAdmin } from "~/permissions";
import { notFoundIfFalsy, parseRequestFormData, validate } from "~/utils/remix";
import { discordFullName } from "~/utils/strings";
import { checkboxValueToBoolean } from "~/utils/zod";
import { findByIdentifier } from "../queries/findByIdentifier.server";
import { findTeamsByEventId } from "../queries/findTeamsByEventId.server";
import { updateIsBeforeStart } from "../queries/updateIsBeforeStart.server";
import { findTeamsByTournamentId } from "../queries/findTeamsByTournamentId.server";
import { updateShowMapListGenerator } from "../queries/updateShowMapListGenerator.server";
import { requireUserId } from "~/modules/auth/user.server";
import { idFromParams } from "../tournament-utils";
const tournamentToolsActionSchema = z.object({
started: z.preprocess(checkboxValueToBoolean, z.boolean()),
});
import {
HACKY_resolveCheckInTime,
tournamentIdFromParams,
validateCanCheckIn,
} from "../tournament-utils";
import { SubmitButton } from "~/components/SubmitButton";
import { UserCombobox } from "~/components/Combobox";
import { adminActionSchema } from "../tournament-schemas.server";
import { changeTeamOwner } from "../queries/changeTeamOwner.server";
import invariant from "tiny-invariant";
import { assertUnreachable } from "~/utils/types";
import { checkIn } from "../queries/checkIn.server";
import { checkOut } from "../queries/checkOut.server";
import hasTournamentStarted from "../queries/hasTournamentStarted.server";
import type { TournamentLoaderData } from "./to.$id";
import { joinTeam, leaveTeam } from "../queries/joinLeaveTeam.server";
import { TOURNAMENT } from "../tournament-constants";
import { deleteTeam } from "../queries/deleteTeam.server";
import { useUser } from "~/modules/auth";
import { calendarEditPage, tournamentPage } from "~/utils/urls";
import { Redirect } from "~/components/Redirect";
export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUserId(request);
const data = await parseRequestFormData({
request,
schema: tournamentToolsActionSchema,
schema: adminActionSchema,
});
const eventId = idFromParams(params);
const eventId = tournamentIdFromParams(params);
const event = notFoundIfFalsy(findByIdentifier(eventId));
const teams = findTeamsByTournamentId(event.id);
validate(canAdminCalendarTOTools({ user, event }));
validate(canAdminTournament({ user, event }), "Unauthorized", 401);
updateIsBeforeStart({
id: event.id,
isBeforeStart: Number(!data.started),
});
switch (data._action) {
case "UPDATE_SHOW_MAP_LIST_GENERATOR": {
updateShowMapListGenerator({
tournamentId: event.id,
showMapListGenerator: Number(data.show),
});
break;
}
case "CHANGE_TEAM_OWNER": {
const team = teams.find((t) => t.id === data.teamId);
validate(team, "Invalid team id");
const oldCaptain = team.members.find((m) => m.isOwner);
invariant(oldCaptain, "Team has no captain");
const newCaptain = team.members.find((m) => m.userId === data.memberId);
validate(newCaptain, "Invalid member id");
changeTeamOwner({
newCaptainId: data.memberId,
oldCaptainId: oldCaptain.userId,
tournamentTeamId: data.teamId,
});
break;
}
case "CHECK_IN": {
const team = teams.find((t) => t.id === data.teamId);
validate(team, "Invalid team id");
validateCanCheckIn({ event, team });
checkIn(team.id);
break;
}
case "CHECK_OUT": {
const team = teams.find((t) => t.id === data.teamId);
validate(team, "Invalid team id");
validate(!hasTournamentStarted(event.id), "Tournament has started");
checkOut(team.id);
break;
}
case "REMOVE_MEMBER": {
const team = teams.find((t) => t.id === data.teamId);
validate(team, "Invalid team id");
validate(!team.checkedInAt, "Team is checked in");
validate(
!team.members.find((m) => m.userId === data.memberId)?.isOwner,
"Cannot remove team owner"
);
leaveTeam({
userId: data.memberId,
teamId: team.id,
});
break;
}
case "ADD_MEMBER": {
const team = teams.find((t) => t.id === data.teamId);
validate(team, "Invalid team id");
validate(
team.members.length < TOURNAMENT.TEAM_MAX_MEMBERS,
"Team is full"
);
validate(
!teams.some((t) =>
t.members.some((m) => m.userId === data["user[value]"])
),
"User is already on a team"
);
joinTeam({
userId: data["user[value]"],
newTeamId: team.id,
});
break;
}
case "DELETE_TEAM": {
const team = teams.find((t) => t.id === data.teamId);
validate(team, "Invalid team id");
validate(!hasTournamentStarted(event.id), "Tournament has started");
deleteTeam(team.id);
break;
}
default: {
assertUnreachable(data);
}
}
return null;
};
export const loader = async ({ params, request }: LoaderArgs) => {
const user = await requireUserId(request);
const eventId = idFromParams(params);
export default function TournamentAdminPage() {
const data = useOutletContext<TournamentLoaderData>();
const user = useUser();
const event = notFoundIfFalsy(findByIdentifier(eventId));
notFoundIfFalsy(canAdminCalendarTOTools({ user, event }));
if (!canAdminTournament({ user, event: data.event })) {
return <Redirect to={tournamentPage(data.event.id)} />;
}
// could also get these from the layout page
// but getting them again for the most fresh data
return {
event,
teams: findTeamsByEventId(event.id),
};
};
export default function TournamentToolsAdminPage() {
const { t } = useTranslation(["tournament"]);
const submit = useSubmit();
const data = useLoaderData<typeof loader>();
const [eventStarted, setEventStarted] = React.useState(
Boolean(!data.event.isBeforeStart)
return (
<div className="stack md">
<AdminActions />
{isAdmin(user) ? <EnableMapList /> : null}
<DownloadParticipants />
<div className="stack items-start mt-4">
<LinkButton
to={calendarEditPage(data.event.eventId)}
size="tiny"
variant="outlined"
>
Edit event info
</LinkButton>
</div>
</div>
);
}
type Input = "USER" | "ROSTER_MEMBER";
const actions = [
{
type: "CHANGE_TEAM_OWNER",
inputs: ["ROSTER_MEMBER"] as Input[],
when: [],
},
{
type: "CHECK_IN",
inputs: [] as Input[],
when: ["CHECK_IN_STARTED", "TOURNAMENT_BEFORE_START"],
},
{
type: "CHECK_OUT",
inputs: [] as Input[],
when: ["CHECK_IN_STARTED", "TOURNAMENT_BEFORE_START"],
},
{
type: "ADD_MEMBER",
inputs: ["USER"] as Input[],
when: [],
},
{
type: "REMOVE_MEMBER",
inputs: ["ROSTER_MEMBER"] as Input[],
when: ["TOURNAMENT_BEFORE_START"],
},
{
type: "DELETE_TEAM",
inputs: [] as Input[],
when: ["TOURNAMENT_BEFORE_START"],
},
] as const;
function AdminActions() {
const fetcher = useFetcher();
const { t } = useTranslation(["tournament"]);
const data = useOutletContext<TournamentLoaderData>();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const [selectedTeamId, setSelectedTeamId] = React.useState(data.teams[0]?.id);
const [selectedAction, setSelectedAction] = React.useState<
(typeof actions)[number]
>(actions[0]);
const selectedTeam = data.teams.find((team) => team.id === selectedTeamId);
const actionsToShow = actions.filter((action) => {
for (const when of action.when) {
switch (when) {
case "CHECK_IN_STARTED": {
if (HACKY_resolveCheckInTime(data.event).getTime() > Date.now()) {
return false;
}
break;
}
case "TOURNAMENT_BEFORE_START": {
if (parentRouteData.hasStarted) {
return false;
}
break;
}
default: {
assertUnreachable(when);
}
}
}
return true;
});
return (
<fetcher.Form
method="post"
className="stack horizontal sm items-end flex-wrap"
>
<div>
<label htmlFor="action">Action</label>
<select
id="action"
name="action"
value={selectedAction.type}
onChange={(e) =>
setSelectedAction(actions.find((a) => a.type === e.target.value)!)
}
>
{actionsToShow.map((action) => (
<option key={action.type} value={action.type}>
{t(`tournament:admin.actions.${action.type}`)}
</option>
))}
</select>
</div>
<div>
<label htmlFor="teamId">Team</label>
<select
id="teamId"
name="teamId"
value={selectedTeamId}
onChange={(e) => setSelectedTeamId(Number(e.target.value))}
>
{data.teams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
</select>
</div>
{selectedTeam && selectedAction.inputs.includes("ROSTER_MEMBER") ? (
<div>
<label htmlFor="memberId">Member</label>
<select id="memberId" name="memberId">
{selectedTeam.members.map((member) => (
<option key={member.userId} value={member.userId}>
{discordFullName(member)}
</option>
))}
</select>
</div>
) : null}
{selectedAction.inputs.includes("USER") ? (
<div>
<label htmlFor="user">User</label>
<UserCombobox inputName="user" id="user" />
</div>
) : null}
<SubmitButton
_action={selectedAction.type}
state={fetcher.state}
variant={
selectedAction.type === "DELETE_TEAM" ? "destructive" : undefined
}
>
Go
</SubmitButton>
</fetcher.Form>
);
}
function EnableMapList() {
const data = useOutletContext<TournamentLoaderData>();
const submit = useSubmit();
const [eventStarted, setEventStarted] = React.useState(
Boolean(data.event.showMapListGenerator)
);
function handleToggle(toggled: boolean) {
setEventStarted(toggled);
const data = new FormData();
data.append("started", toggled ? "on" : "off");
data.append("_action", "UPDATE_SHOW_MAP_LIST_GENERATOR");
data.append("show", toggled ? "on" : "off");
submit(data, { method: "post" });
}
function discordListContent() {
return (
<div>
<label>Public map list generator tool</label>
<Toggle checked={eventStarted} setChecked={handleToggle} name="show" />
</div>
);
}
function DownloadParticipants() {
const { t } = useTranslation(["tournament"]);
const data = useOutletContext<TournamentLoaderData>();
function allParticipantsContent() {
return data.teams
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
@ -88,34 +346,47 @@ export default function TournamentToolsAdminPage() {
.join("\n");
}
function notCheckedInParticipantsContent() {
return data.teams
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.filter((team) => !team.checkedInAt)
.map((team) => {
return `${team.name} - ${team.members
.map(
(member) => `${discordFullName(member)} - <@${member.discordId}>`
)
.join(" / ")}`;
})
.join("\n");
}
return (
<div className="stack md half-width">
<div>
<label>{t("tournament:admin.eventStarted")}</label>
<Toggle
checked={eventStarted}
setChecked={handleToggle}
name="started"
/>
<FormMessage type="info">
{t("tournament:admin.eventStarted.explanation")}
</FormMessage>
</div>
<div>
<label>{t("tournament:admin.download")}</label>
<div className="stack horizontal sm">
<Button
size="tiny"
onClick={() =>
handleDownload({
filename: "discord.txt",
content: discordListContent(),
})
}
>
{t("tournament:admin.download.discord")}
</Button>
</div>
<div>
<label>{t("tournament:admin.download")} (Discord format)</label>
<div className="stack horizontal sm">
<Button
size="tiny"
onClick={() =>
handleDownload({
filename: "all-participants.txt",
content: allParticipantsContent(),
})
}
>
All participants
</Button>
<Button
size="tiny"
onClick={() =>
handleDownload({
filename: "not-checked-in-participants.txt",
content: notCheckedInParticipantsContent(),
})
}
>
Not checked in participants
</Button>
</div>
</div>
);

View File

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

View File

@ -6,74 +6,100 @@ import { SubmitButton } from "~/components/SubmitButton";
import { INVITE_CODE_LENGTH } from "~/constants";
import { useUser } from "~/modules/auth";
import { requireUserId } from "~/modules/auth/user.server";
import { notFoundIfFalsy, validate } from "~/utils/remix";
import { notFoundIfFalsy, parseRequestFormData, validate } from "~/utils/remix";
import { assertUnreachable } from "~/utils/types";
import { toToolsPage } from "~/utils/urls";
import { tournamentPage } from "~/utils/urls";
import { findByInviteCode } from "../queries/findTeamByInviteCode.server";
import type { FindTeamsByEventIdItem } from "../queries/findTeamsByEventId.server";
import { findTeamsByEventId } from "../queries/findTeamsByEventId.server";
import { joinTeam } from "../queries/joinTeam.server";
import { findTeamsByTournamentId } from "../queries/findTeamsByTournamentId.server";
import { joinTeam } from "../queries/joinLeaveTeam.server";
import { TOURNAMENT } from "../tournament-constants";
import type { TournamentToolsLoaderData } from "./to.$id";
// TODO: handle tournament over
// 1) no team, can join
// 2) team but not captain, can leave and join IF tournament not checked in
// 3) team and captain, can join, tournament disbands IF tournament not checked in
import type { TournamentLoaderData, TournamentLoaderTeam } from "./to.$id";
import hasTournamentStarted from "../queries/hasTournamentStarted.server";
import React from "react";
import { discordFullName } from "~/utils/strings";
import { joinSchema } from "../tournament-schemas.server";
import { giveTrust } from "../queries/giveTrust.server";
export const action: ActionFunction = async ({ request }) => {
const user = await requireUserId(request);
const url = new URL(request.url);
const inviteCode = url.searchParams.get("code");
// TODO tournament: don't throw here
const data = await parseRequestFormData({ request, schema: joinSchema });
invariant(inviteCode, "code is missing");
const leanTeam = notFoundIfFalsy(findByInviteCode(inviteCode));
const teams = findTeamsByEventId(leanTeam.calendarEventId);
const teams = findTeamsByTournamentId(leanTeam.tournamentId);
validate(
!hasTournamentStarted(leanTeam.tournamentId),
"Tournament has started"
);
const teamToJoin = teams.find((team) => team.id === leanTeam.id);
const previousTeam = teams.find((team) =>
team.members.some((member) => member.userId === user.id)
);
validate(teamToJoin);
validate(teamToJoin, "Not team of this tournament");
validate(
validateCanJoin({ inviteCode, teamToJoin, userId: user.id }) === "VALID"
validateCanJoin({ inviteCode, teamToJoin, userId: user.id }) === "VALID",
"Invite code is invalid"
);
const whatToDoWithPreviousTeam = !previousTeam
? undefined
: previousTeam.members.some(
(member) => member.userId === user.id && member.isOwner
)
? "DELETE"
: "LEAVE";
joinTeam({
userId: user.id,
newTeamId: teamToJoin.id,
previousTeamId: previousTeam?.id,
whatToDoWithPreviousTeam: !previousTeam
? undefined
: previousTeam.members.some(
(member) => member.userId === user.id && member.isOwner
)
? "DELETE"
: "LEAVE",
// making sure they aren't unfilling one checking in condition i.e. having full roster
// and then having members leave without it affecting the checking in status
checkOutTeam:
whatToDoWithPreviousTeam === "LEAVE" &&
previousTeam &&
previousTeam.members.length <= TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL,
whatToDoWithPreviousTeam,
});
if (data.trust) {
const inviterUserId = teamToJoin.members.find(
(member) => member.isOwner
)?.userId;
invariant(inviterUserId, "Inviter user could not be resolved");
giveTrust({
trustGiverUserId: user.id,
trustReceiverUserId: inviterUserId,
});
}
return redirect(toToolsPage(leanTeam.calendarEventId));
return redirect(tournamentPage(leanTeam.tournamentId));
};
export const loader = ({ request }: LoaderArgs) => {
const url = new URL(request.url);
const inviteCode = url.searchParams.get("code");
invariant(inviteCode, "code is missing");
return { teamId: findByInviteCode(inviteCode)?.id, inviteCode };
return {
teamId: inviteCode ? findByInviteCode(inviteCode)?.id : null,
inviteCode,
};
};
export default function JoinTeamPage() {
const id = React.useId();
const user = useUser();
const parentRouteData = useOutletContext<TournamentToolsLoaderData>();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const data = useLoaderData<typeof loader>();
const teamToJoin = parentRouteData.teams.find(
(team) => team.id === data.teamId
);
const captain = teamToJoin?.members.find((member) => member.isOwner);
const validationStatus = validateCanJoin({
inviteCode: data.inviteCode,
teamToJoin,
@ -82,8 +108,11 @@ export default function JoinTeamPage() {
const textPrompt = () => {
switch (validationStatus) {
case "MISSING_CODE": {
return "Invite code is missing. Was the full URL copied?";
}
case "SHORT_CODE": {
return "Invite code is not the right length. Did you copy the full URL?";
return "Invite code is not the right length. Was the full URL copied?";
}
case "NO_TEAM_MATCHING_CODE": {
return "No team matching the invite code.";
@ -110,7 +139,18 @@ export default function JoinTeamPage() {
return (
<Form method="post" className="tournament__invite-container">
<div className="text-center">{textPrompt()}</div>
<div className="stack sm">
<div className="text-center">{textPrompt()}</div>
{validationStatus === "VALID" ? (
<div className="text-lighter text-sm stack horizontal sm items-center">
<input id={id} type="checkbox" name="trust" />{" "}
<label htmlFor={id} className="mb-0">
Trust {captain ? discordFullName(captain) : ""} to add you on
their own to future tournaments?
</label>
</div>
) : null}
</div>
{validationStatus === "VALID" ? (
<SubmitButton size="big">Join</SubmitButton>
) : null}
@ -123,10 +163,13 @@ function validateCanJoin({
teamToJoin,
userId,
}: {
inviteCode: string;
teamToJoin?: FindTeamsByEventIdItem;
inviteCode?: string | null;
teamToJoin?: TournamentLoaderTeam;
userId?: number;
}) {
if (typeof inviteCode !== "string") {
return "MISSING_CODE";
}
if (typeof userId !== "number") {
return "NOT_LOGGED_IN";
}

View File

@ -16,12 +16,12 @@ import {
import mapsStyles from "~/styles/maps.css";
import { type SendouRouteHandle } from "~/utils/remix";
import { TOURNAMENT } from "../tournament-constants";
import type { TournamentToolsLoaderData } from "./to.$id";
import type { TournamentLoaderData } from "./to.$id";
import type { MapPoolMap } from "~/db/types";
import { modesIncluded, resolveOwnedTeam } from "../tournament-utils";
import { useUser } from "~/modules/auth";
import { Redirect } from "~/components/Redirect";
import { toToolsPage } from "~/utils/urls";
import { tournamentPage } from "~/utils/urls";
export const links: LinksFunction = () => {
return [{ rel: "stylesheet", href: mapsStyles }];
@ -36,11 +36,11 @@ type TeamInState = {
mapPool?: Pick<MapPoolMap, "mode" | "stageId">[];
};
export default function TournamentToolsMapsPage() {
export default function TournamentMapsPage() {
const user = useUser();
const { t } = useTranslation(["tournament"]);
const actionData = useActionData<{ failed?: boolean }>();
const data = useOutletContext<TournamentToolsLoaderData>();
const data = useOutletContext<TournamentLoaderData>();
const [bestOf, setBestOf] = useSearchParamState<
(typeof TOURNAMENT)["AVAILABLE_BEST_OF"][number]
@ -85,7 +85,7 @@ export default function TournamentToolsMapsPage() {
};
if (!data.mapListGeneratorAvailable) {
return <Redirect to={toToolsPage(data.event.id)} />;
return <Redirect to={tournamentPage(data.event.id)} />;
}
return (
@ -124,8 +124,7 @@ export default function TournamentToolsMapsPage() {
{ ...teamTwo, maps: new MapPool(teamTwo.mapPool ?? []) },
]}
bestOf={bestOf}
bracketType={bracketType}
roundNumber={roundNumber}
seed={`${bracketType}-${roundNumber}`}
modesIncluded={modesIncluded(data.event)}
/>
</div>
@ -169,8 +168,8 @@ function RoundSelect({
bracketType,
handleChange,
}: {
roundNumber: TournamentMaplistInput["roundNumber"];
bracketType: TournamentMaplistInput["bracketType"];
roundNumber: number;
bracketType: string;
handleChange: (roundNumber: number, bracketType: BracketType) => void;
}) {
const { t } = useTranslation(["tournament"]);
@ -215,7 +214,7 @@ function TeamsSelect({
setTeam: (newTeamId: number) => void;
}) {
const { t } = useTranslation(["tournament"]);
const data = useOutletContext<TournamentToolsLoaderData>();
const data = useOutletContext<TournamentLoaderData>();
return (
<div className="tournament__select-container">
@ -275,7 +274,7 @@ function BestOfRadios({
function MapList(props: Omit<TournamentMaplistInput, "tiebreakerMaps">) {
const { t } = useTranslation(["game-misc"]);
const data = useOutletContext<TournamentToolsLoaderData>();
const data = useOutletContext<TournamentLoaderData>();
let mapList: Array<TournamentMapListMap>;

View File

@ -17,7 +17,7 @@ import { Label } from "~/components/Label";
import { SubmitButton } from "~/components/SubmitButton";
import { useTranslation } from "~/hooks/useTranslation";
import { useUser } from "~/modules/auth";
import { getUserId, requireUserId } from "~/modules/auth/user.server";
import { getUser, requireUser } from "~/modules/auth/user.server";
import type {
ModeShort,
RankedModeShort,
@ -41,13 +41,13 @@ import {
SENDOU_INK_BASE_URL,
modeImageUrl,
navIconUrl,
toToolsJoinPage,
toToolsMapsPage,
tournamentBracketsPage,
tournamentJoinPage,
} from "~/utils/urls";
import deleteTeamMember from "../queries/deleteTeamMember.server";
import { findByIdentifier } from "../queries/findByIdentifier.server";
import { findOwnTeam } from "../queries/findOwnTeam.server";
import { findTeamsByEventId } from "../queries/findTeamsByEventId.server";
import { findTeamsByTournamentId } from "../queries/findTeamsByTournamentId.server";
import { updateTeamInfo } from "../queries/updateTeamInfo.server";
import { upsertCounterpickMaps } from "../queries/upsertCounterpickMaps.server";
import { TOURNAMENT } from "../tournament-constants";
@ -56,16 +56,28 @@ import { registerSchema } from "../tournament-schemas.server";
import {
isOneModeTournamentOf,
HACKY_resolvePicture,
idFromParams,
tournamentIdFromParams,
resolveOwnedTeam,
HACKY_resolveCheckInTime,
validateCanCheckIn,
} from "../tournament-utils";
import type { TournamentToolsLoaderData } from "./to.$id";
import type { TournamentLoaderData } from "./to.$id";
import { createTeam } from "../queries/createTeam.server";
import { ClockIcon } from "~/components/icons/Clock";
import { databaseTimestampToDate } from "~/utils/dates";
import { UserIcon } from "~/components/icons/User";
import { useIsMounted } from "~/hooks/useIsMounted";
import hasTournamentStarted from "../queries/hasTournamentStarted.server";
import { CheckmarkIcon } from "~/components/icons/Checkmark";
import { CrossIcon } from "~/components/icons/Cross";
import clsx from "clsx";
import { checkIn } from "../queries/checkIn.server";
import { useAutoRerender } from "~/hooks/useAutoRerender";
import type { TrustedPlayer } from "../queries/findTrustedPlayers.server";
import { findTrustedPlayers } from "../queries/findTrustedPlayers.server";
import { Divider } from "~/components/Divider";
import { joinTeam } from "../queries/joinLeaveTeam.server";
import { booleanToInt } from "~/utils/sql";
export const handle: SendouRouteHandle = {
breadcrumb: () => ({
@ -76,19 +88,19 @@ export const handle: SendouRouteHandle = {
};
export const action: ActionFunction = async ({ request, params }) => {
const user = await requireUserId(request);
const user = await requireUser(request);
const data = await parseRequestFormData({ request, schema: registerSchema });
const eventId = idFromParams(params);
const event = notFoundIfFalsy(findByIdentifier(eventId));
const tournamentId = tournamentIdFromParams(params);
const hasStarted = hasTournamentStarted(tournamentId);
const event = notFoundIfFalsy(findByIdentifier(tournamentId));
validate(
event.isBeforeStart,
400,
!hasStarted,
"Tournament has started, cannot make edits to registration"
);
const teams = findTeamsByEventId(eventId);
const teams = findTeamsByTournamentId(tournamentId);
const ownTeam = teams.find((team) =>
team.members.some((member) => member.userId === user.id && member.isOwner)
);
@ -99,12 +111,14 @@ export const action: ActionFunction = async ({ request, params }) => {
updateTeamInfo({
name: data.teamName,
id: ownTeam.id,
prefersNotToHost: booleanToInt(data.prefersNotToHost),
});
} else {
createTeam({
name: data.teamName,
calendarEventId: eventId,
tournamentId: tournamentId,
ownerId: user.id,
prefersNotToHost: booleanToInt(data.prefersNotToHost),
});
}
break;
@ -114,6 +128,18 @@ export const action: ActionFunction = async ({ request, params }) => {
validate(ownTeam.members.some((member) => member.userId === data.userId));
validate(data.userId !== user.id);
const detailedOwnTeam = findOwnTeam({
tournamentId,
userId: user.id,
});
// making sure they aren't unfilling one checking in condition i.e. having full roster
// and then having members kicked without it affecting the checking in status
validate(
detailedOwnTeam &&
(!detailedOwnTeam.checkedInAt ||
ownTeam.members.length > TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL)
);
deleteTeamMember({ tournamentTeamId: ownTeam.id, userId: data.userId });
break;
}
@ -131,6 +157,35 @@ export const action: ActionFunction = async ({ request, params }) => {
});
break;
}
case "CHECK_IN": {
validate(ownTeam);
validateCanCheckIn({ event, team: ownTeam });
checkIn(ownTeam.id);
break;
}
case "ADD_PLAYER": {
validate(
teams.every((team) =>
team.members.every((member) => member.userId !== data.userId)
),
"User is already in a team"
);
validate(ownTeam);
validate(
findTrustedPlayers({
userId: user.id,
teamId: user.team?.id,
}).some((trustedPlayer) => trustedPlayer.id === data.userId),
"No trust given from this user"
);
joinTeam({
userId: data.userId,
newTeamId: ownTeam.id,
});
break;
}
default: {
assertUnreachable(data);
}
@ -140,24 +195,28 @@ export const action: ActionFunction = async ({ request, params }) => {
};
export const loader = async ({ request, params }: LoaderArgs) => {
const eventId = idFromParams(params);
const event = notFoundIfFalsy(findByIdentifier(eventId));
const eventId = tournamentIdFromParams(params);
const hasStarted = hasTournamentStarted(eventId);
if (!event.isBeforeStart) {
throw redirect(toToolsMapsPage(event.id));
if (hasStarted) {
throw redirect(tournamentBracketsPage(eventId));
}
const user = await getUserId(request);
const user = await getUser(request);
if (!user) return null;
const ownTeam = findOwnTeam({
calendarEventId: idFromParams(params),
tournamentId: tournamentIdFromParams(params),
userId: user.id,
});
if (!ownTeam) return null;
return {
ownTeam,
trustedPlayers: findTrustedPlayers({
userId: user.id,
teamId: user.team?.id,
}),
};
};
@ -166,7 +225,7 @@ export default function TournamentRegisterPage() {
const { i18n } = useTranslation();
const user = useUser();
const data = useLoaderData<typeof loader>();
const parentRouteData = useOutletContext<TournamentToolsLoaderData>();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const teamRegularMemberOf = parentRouteData.teams.find((team) =>
team.members.some((member) => member.userId === user?.id && !member.isOwner)
@ -232,73 +291,240 @@ function RegistrationForms({
ownTeam?: NonNullable<SerializeFrom<typeof loader>>["ownTeam"];
}) {
const user = useUser();
const parentRouteData = useOutletContext<TournamentLoaderData>();
if (!user) return <PleaseLogIn />;
const ownTeamFromList = resolveOwnedTeam({
teams: parentRouteData.teams,
userId: user?.id,
});
return (
<div className="stack lg">
<RegisterToBracket />
<TeamInfo ownTeam={ownTeam} />
<RegistrationProgress
checkedIn={Boolean(ownTeam?.checkedInAt)}
name={ownTeam?.name}
mapPool={ownTeamFromList?.mapPool}
members={ownTeamFromList?.members}
/>
<TeamInfo
name={ownTeam?.name}
prefersNotToHost={ownTeamFromList?.prefersNotToHost}
/>
{ownTeam ? (
<>
<FillRoster ownTeam={ownTeam} />
<CounterPickMapPoolPicker />
<RememberToCheckin />
</>
) : null}
</div>
);
}
function RegisterToBracket() {
const parentRouteData = useOutletContext<TournamentToolsLoaderData>();
function RegistrationProgress({
checkedIn,
name,
members,
mapPool,
}: {
checkedIn?: boolean;
name?: string;
members?: unknown[];
mapPool?: unknown[];
}) {
const parentRouteData = useOutletContext<TournamentLoaderData>();
const steps = [
{
name: "Team name",
completed: Boolean(name),
},
{
name: "Full roster",
completed:
members && members.length >= TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL,
},
{
name: "Map pool",
completed: mapPool && mapPool.length > 0,
},
{
name: "Check-in",
completed: checkedIn,
},
];
const checkInStartsDate = HACKY_resolveCheckInTime(parentRouteData.event);
const checkInEndsDate = databaseTimestampToDate(
parentRouteData.event.startTime
);
const now = new Date();
const checkInIsOpen =
now.getTime() > checkInStartsDate.getTime() &&
now.getTime() < checkInEndsDate.getTime();
const checkInIsOver =
now.getTime() > checkInEndsDate.getTime() &&
now.getTime() > checkInStartsDate.getTime();
return (
<div>
<h3 className="tournament__section-header">1. Register</h3>
<section className="tournament__section text-center text-sm font-semi-bold">
Register on{" "}
<a
href={parentRouteData.event.bracketUrl}
target="_blank"
rel="noopener noreferrer"
>
{parentRouteData.event.bracketUrl}
</a>
<h3 className="tournament__section-header text-center">
Complete these steps to play
</h3>
<section className="tournament__section stack md">
<div className="stack horizontal lg justify-center text-sm font-semi-bold">
{steps.map((step, i) => {
return (
<div
key={step.name}
className="stack sm items-center text-center"
>
{step.name}
{step.completed ? (
<CheckmarkIcon
className="tournament__section__icon fill-success"
testId={`checkmark-icon-num-${i + 1}`}
/>
) : (
<CrossIcon className="tournament__section__icon fill-error" />
)}
</div>
);
})}
</div>
{!checkedIn ? (
<CheckIn
canCheckIn={steps.filter((step) => !step.completed).length === 1}
status={
checkInIsOpen ? "OPEN" : checkInIsOver ? "OVER" : "UPCOMING"
}
startDate={checkInStartsDate}
endDate={checkInEndsDate}
/>
) : null}
</section>
<div className="tournament__section__warning">
Free editing of any information before the tournament starts allowed.
</div>
</div>
);
}
function TeamInfo({
ownTeam,
function CheckIn({
status,
canCheckIn,
startDate,
endDate,
}: {
ownTeam?: NonNullable<SerializeFrom<typeof loader>>["ownTeam"];
status: "OVER" | "OPEN" | "UPCOMING";
canCheckIn: boolean;
startDate: Date;
endDate: Date;
}) {
const { i18n } = useTranslation();
const isMounted = useIsMounted();
const fetcher = useFetcher();
useAutoRerender();
const checkInStartsString = isMounted
? startDate.toLocaleTimeString(i18n.language, {
minute: "numeric",
hour: "numeric",
day: "2-digit",
month: "2-digit",
})
: "";
const checkInEndsString = isMounted
? endDate.toLocaleTimeString(i18n.language, {
minute: "numeric",
hour: "numeric",
day: "2-digit",
month: "2-digit",
})
: "";
if (status === "UPCOMING") {
return (
<div className={clsx("text-center text-xs", { invisible: !isMounted })}>
Check-in is open between {checkInStartsString} and {checkInEndsString}
</div>
);
}
if (status === "OVER") {
return <div className="text-center text-xs">Check-in is over</div>;
}
return (
<fetcher.Form method="post" className="stack items-center">
<SubmitButton
size="tiny"
_action="CHECK_IN"
// TODO: better UX than just disabling the button
// do they have other steps left to complete than checking in?
disabled={!canCheckIn}
state={fetcher.state}
testId="check-in-button"
>
Check in
</SubmitButton>
</fetcher.Form>
);
}
function TeamInfo({
name,
prefersNotToHost = 0,
}: {
name?: string;
prefersNotToHost?: number;
}) {
const id = React.useId();
const fetcher = useFetcher();
return (
<div>
<h3 className="tournament__section-header">2. Team info</h3>
<h3 className="tournament__section-header">1. Team info</h3>
<section className="tournament__section">
<fetcher.Form method="post" className="stack md items-center">
<div className="tournament__section__input-container">
<Label htmlFor="teamName">Team name</Label>
<Input
name="teamName"
id="teamName"
required
maxLength={TOURNAMENT.TEAM_NAME_MAX_LENGTH}
defaultValue={ownTeam?.name ?? undefined}
/>
<div className="stack sm items-center">
<div className="tournament__section__input-container">
<Label htmlFor="teamName">Team name</Label>
<Input
name="teamName"
id="teamName"
required
maxLength={TOURNAMENT.TEAM_NAME_MAX_LENGTH}
defaultValue={name ?? undefined}
/>
</div>
<div>
<div className="text-lighter text-sm stack horizontal sm items-center">
<input
id={id}
type="checkbox"
name="prefersNotToHost"
defaultChecked={Boolean(prefersNotToHost)}
/>
<label htmlFor={id} className="mb-0">
My team prefers not to host rooms
</label>
</div>
</div>
</div>
<SubmitButton _action="UPSERT_TEAM" state={fetcher.state}>
<SubmitButton
_action="UPSERT_TEAM"
state={fetcher.state}
testId="save-team-button"
>
Save
</SubmitButton>
</fetcher.Form>
</section>
<div className="tournament__section__warning">
Use the same name as on the bracket
</div>
</div>
);
}
@ -308,12 +534,13 @@ function FillRoster({
}: {
ownTeam: NonNullable<SerializeFrom<typeof loader>>["ownTeam"];
}) {
const data = useLoaderData<typeof loader>();
const user = useUser();
const parentRouteData = useOutletContext<TournamentToolsLoaderData>();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const [, copyToClipboard] = useCopyToClipboard();
const { t } = useTranslation(["common"]);
const inviteLink = `${SENDOU_INK_BASE_URL}${toToolsJoinPage({
const inviteLink = `${SENDOU_INK_BASE_URL}${tournamentJoinPage({
eventId: parentRouteData.event.id,
inviteCode: ownTeam.inviteCode,
})}`;
@ -330,28 +557,59 @@ function FillRoster({
0
);
const showDeleteMemberSection = ownTeamMembers.length > 1;
const optionalMembers = Math.max(
TOURNAMENT.TEAM_MAX_MEMBERS - ownTeamMembers.length - missingMembers,
0
);
const showDeleteMemberSection =
(!ownTeam.checkedInAt && ownTeamMembers.length > 1) ||
(ownTeam.checkedInAt &&
ownTeamMembers.length > TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL);
const playersAvailableToDirectlyAdd = (() => {
return data!.trustedPlayers.filter((user) => {
return parentRouteData.teams.every((team) =>
team.members.every((member) => member.userId !== user.id)
);
});
})();
const teamIsFull = ownTeamMembers.length >= TOURNAMENT.TEAM_MAX_MEMBERS;
return (
<div>
<h3 className="tournament__section-header">3. Fill roster</h3>
<h3 className="tournament__section-header">2. Fill roster</h3>
<section className="tournament__section stack lg items-center">
<div className="stack md items-center">
<div className="text-center text-sm">
Share your invite link to add members: {inviteLink}
{playersAvailableToDirectlyAdd.length > 0 && !teamIsFull ? (
<>
<DirectlyAddPlayerSelect players={playersAvailableToDirectlyAdd} />
<Divider>OR</Divider>
</>
) : null}
{!teamIsFull ? (
<div className="stack md items-center">
<div className="text-center text-sm">
Share your invite link to add members: {inviteLink}
</div>
<div>
<Button
size="tiny"
onClick={() => copyToClipboard(inviteLink)}
variant="outlined"
>
{t("common:actions.copyToClipboard")}
</Button>
</div>
</div>
<div>
<Button size="tiny" onClick={() => copyToClipboard(inviteLink)}>
{t("common:actions.copyToClipboard")}
</Button>
</div>
</div>
<div className="stack lg horizontal mt-2">
{ownTeamMembers.map((member) => {
) : null}
<div className="stack lg horizontal mt-2 flex-wrap justify-center">
{ownTeamMembers.map((member, i) => {
return (
<div
key={member.userId}
className="stack sm items-center text-sm"
data-testid={`member-num-${i + 1}`}
>
<Avatar size="xsm" user={member} />
{member.discordName}
@ -365,23 +623,61 @@ function FillRoster({
</div>
);
})}
{new Array(optionalMembers).fill(null).map((_, i) => {
return (
<div
key={i}
className="tournament__missing-player tournament__missing-player__optional"
>
?
</div>
);
})}
</div>
{showDeleteMemberSection ? (
<DeleteMember members={ownTeamMembers} />
) : null}
</section>
<div className="tournament__section__warning">
You can still play without submitting roster, but you might be seeded
lower in the bracket.
At least {TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL} members are required to
participate. Max roster size is {TOURNAMENT.TEAM_MAX_MEMBERS}.
</div>
</div>
);
}
function DirectlyAddPlayerSelect({ players }: { players: TrustedPlayer[] }) {
const fetcher = useFetcher();
const id = React.useId();
return (
<fetcher.Form method="post" className="stack horizontal sm items-end">
<div>
<Label htmlFor={id}>Add people you have played with</Label>
<select id={id} name="userId">
{players.map((player) => {
return (
<option key={player.id} value={player.id}>
{discordFullName(player)}
</option>
);
})}
</select>
</div>
<SubmitButton
_action="ADD_PLAYER"
state={fetcher.state}
testId="add-player-button"
>
Add
</SubmitButton>
</fetcher.Form>
);
}
function DeleteMember({
members,
}: {
members: Unpacked<TournamentToolsLoaderData["teams"]>["members"];
members: Unpacked<TournamentLoaderData["teams"]>["members"];
}) {
const id = React.useId();
const fetcher = useFetcher();
@ -424,9 +720,11 @@ function DeleteMember({
);
}
// TODO: when "Can't pick stage more than 2 times" highlight those selects in red
// TODO: useBlocker to prevent leaving page if made changes without saving
function CounterPickMapPoolPicker() {
const { t } = useTranslation(["common", "game-misc"]);
const parentRouteData = useOutletContext<TournamentToolsLoaderData>();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const fetcher = useFetcher();
const { counterpickMaps, handleCounterpickMapPoolSelect } =
@ -447,7 +745,7 @@ function CounterPickMapPoolPicker() {
return (
<div>
<h3 className="tournament__section-header">4. Pick map pool</h3>
<h3 className="tournament__section-header">3. Pick map pool</h3>
<section className="tournament__section">
<fetcher.Form
method="post"
@ -503,6 +801,9 @@ function CounterPickMapPoolPicker() {
<select
value={counterpickMaps[mode][i] ?? undefined}
onChange={handleCounterpickMapPoolSelect(mode, i)}
data-testid={`counterpick-map-pool-${mode}-num-${
i + 1
}`}
>
<option value=""></option>
{stageIds
@ -529,6 +830,7 @@ function CounterPickMapPoolPicker() {
_action="UPDATE_MAP_POOL"
state={fetcher.state}
className="self-center mt-4"
testId="save-map-list-button"
>
{t("common:actions.save")}
</SubmitButton>
@ -542,10 +844,6 @@ function CounterPickMapPoolPicker() {
)}
</fetcher.Form>
</section>
<div className="tournament__section__warning">
Picking a map pool is optional, but if you don&apos;t then you will be
playing on your opponent&apos;s picks.
</div>
</div>
);
}
@ -557,7 +855,11 @@ function MapPoolValidationStatusMessage({
}) {
const { t } = useTranslation(["common"]);
if (status !== "TOO_MUCH_STAGE_REPEAT") return null;
if (
status !== "TOO_MUCH_STAGE_REPEAT" &&
status !== "STAGE_REPEAT_IN_SAME_MODE"
)
return null;
return (
<div className="mt-4">
@ -573,7 +875,8 @@ function MapPoolValidationStatusMessage({
type CounterPickValidationStatus =
| "PICKING"
| "VALID"
| "TOO_MUCH_STAGE_REPEAT";
| "TOO_MUCH_STAGE_REPEAT"
| "STAGE_REPEAT_IN_SAME_MODE";
function validateCounterPickMapPool(
mapPool: MapPool,
@ -595,6 +898,13 @@ function validateCounterPickMapPool(
stageCounts.set(stageId, stageCounts.get(stageId)! + 1);
}
if (
new MapPool(mapPool.serialized).stageModePairs.length !==
mapPool.stageModePairs.length
) {
return "STAGE_REPEAT_IN_SAME_MODE";
}
if (
!isOneModeOnlyTournamentFor &&
(mapPool.parsed.SZ.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE ||
@ -615,35 +925,3 @@ function validateCounterPickMapPool(
return "VALID";
}
function RememberToCheckin() {
const { i18n } = useTranslation();
const isMounted = useIsMounted();
const parentRouteData = useOutletContext<TournamentToolsLoaderData>();
const checkInStartsString = isMounted
? HACKY_resolveCheckInTime(parentRouteData.event).toLocaleTimeString(
i18n.language,
{
minute: "numeric",
hour: "numeric",
}
)
: "";
return (
<div>
<h3 className="tournament__section-header">5. Check-in</h3>
<section className="tournament__section text-center text-sm font-semi-bold">
Check in starts at {checkInStartsString} here:{" "}
<a
href={parentRouteData.event.bracketUrl}
target="_blank"
rel="noopener noreferrer"
>
{parentRouteData.event.bracketUrl}
</a>
</section>
</div>
);
}

View File

@ -0,0 +1,316 @@
import {
closestCenter,
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import type { LoaderArgs } from "@remix-run/node";
import { type ActionFunction, redirect } from "@remix-run/node";
import {
useFetcher,
useLoaderData,
useMatches,
useNavigation,
useOutletContext,
} from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
import invariant from "tiny-invariant";
import { Alert } from "~/components/Alert";
import { Button } from "~/components/Button";
import { Catcher } from "~/components/Catcher";
import { Draggable } from "~/components/Draggable";
import { useTimeoutState } from "~/hooks/useTimeoutState";
import type { TournamentLoaderData, TournamentLoaderTeam } from "./to.$id";
import { Image } from "~/components/Image";
import { navIconUrl, tournamentBracketsPage } from "~/utils/urls";
import { maxXPowers } from "../queries/maxXPowers.server";
import { requireUser } from "~/modules/auth";
import { notFoundIfFalsy, parseRequestFormData, validate } from "~/utils/remix";
import { seedsActionSchema } from "../tournament-schemas.server";
import { updateTeamSeeds } from "../queries/updateTeamSeeds.server";
import { tournamentIdFromParams } from "../tournament-utils";
import hasTournamentStarted from "../queries/hasTournamentStarted.server";
import { canAdminTournament } from "~/permissions";
import { findByIdentifier } from "../queries/findByIdentifier.server";
import { SubmitButton } from "~/components/SubmitButton";
import clone from "just-clone";
export const action: ActionFunction = async ({ request, params }) => {
const data = await parseRequestFormData({
request,
schema: seedsActionSchema,
});
const user = await requireUser(request);
const tournamentId = tournamentIdFromParams(params);
const tournament = notFoundIfFalsy(findByIdentifier(tournamentId));
const hasStarted = hasTournamentStarted(tournamentId);
validate(canAdminTournament({ user, event: tournament }));
validate(hasStarted, "Tournament has started");
updateTeamSeeds({ tournamentId, teamIds: data.seeds });
return null;
};
export const loader = async ({ params, request }: LoaderArgs) => {
const user = await requireUser(request);
const tournamentId = tournamentIdFromParams(params);
const hasStarted = hasTournamentStarted(tournamentId);
const tournament = notFoundIfFalsy(findByIdentifier(tournamentId));
if (!canAdminTournament({ user, event: tournament }) || hasStarted) {
throw redirect(tournamentBracketsPage(tournamentId));
}
return {
xPowers: maxXPowers(),
};
};
export default function TournamentSeedsPage() {
const data = useLoaderData<typeof loader>();
const [, parentRoute] = useMatches();
const { teams } = parentRoute.data as TournamentLoaderData;
const navigation = useNavigation();
const [teamOrder, setTeamOrder] = React.useState(teams.map((t) => t.id));
const [activeTeam, setActiveTeam] =
React.useState<TournamentLoaderTeam | null>(null);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const teamsSorted = teams.sort(
(a, b) => teamOrder.indexOf(a.id) - teamOrder.indexOf(b.id)
);
const plusTierToRank: Record<number, number> = {
999: 1000,
3: 2000,
2: 3000,
1: 4000,
} as const;
const rankTeam = (team: TournamentLoaderTeam) => {
const plusTiers = team.members.map((m) => m.plusTier ?? 999);
plusTiers.sort((a, b) => a - b);
plusTiers.slice(0, 4);
const xPowers = team.members
.map((m) => data.xPowers[m.userId])
.filter(Boolean);
xPowers.sort((a, b) => b - a);
xPowers.slice(0, 4);
return (
xPowers.reduce((acc, xPower) => acc + xPower / 10, 0) +
plusTiers.reduce((acc, plusTier) => acc + plusTierToRank[plusTier], 0)
);
};
return (
<div className="stack lg">
<SeedAlert teamOrder={teamOrder} />
<Button
className="tournament__seeds__order-button"
variant="minimal"
size="tiny"
type="button"
onClick={() => {
setTeamOrder(
clone(teams)
.sort((a, b) => rankTeam(b) - rankTeam(a))
.map((t) => t.id)
);
}}
>
Sort automatically
</Button>
<ul>
<li className="tournament__seeds__teams-list-row">
<div className="tournament__seeds__teams-container__header">Seed</div>
<div className="tournament__seeds__teams-container__header">Name</div>
<div className="tournament__seeds__teams-container__header">
Players
</div>
</li>
<DndContext
id="team-seed-sorter"
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={(event) => {
const newActiveTeam = teamsSorted.find(
(t) => t.id === event.active.id
);
invariant(newActiveTeam, "newActiveTeam is undefined");
setActiveTeam(newActiveTeam);
}}
onDragEnd={(event) => {
const { active, over } = event;
if (!over) return;
setActiveTeam(null);
if (active.id !== over.id) {
setTeamOrder((teamIds) => {
const oldIndex = teamIds.indexOf(active.id as number);
const newIndex = teamIds.indexOf(over.id as number);
return arrayMove(teamIds, oldIndex, newIndex);
});
}
}}
>
<SortableContext
items={teamOrder}
strategy={verticalListSortingStrategy}
>
{teamsSorted.map((team, i) => (
<Draggable
key={team.id}
id={team.id}
disabled={navigation.state !== "idle"}
liClassName={clsx(
"tournament__seeds__teams-list-row",
"sortable",
{
disabled: navigation.state !== "idle",
invisible: activeTeam?.id === team.id,
}
)}
>
<RowContents team={team} seed={i + 1} />
</Draggable>
))}
</SortableContext>
<DragOverlay>
{activeTeam && (
<li className="tournament__seeds__teams-list-row active">
<RowContents team={activeTeam} />
</li>
)}
</DragOverlay>
</DndContext>
</ul>
</div>
);
}
function SeedAlert({ teamOrder }: { teamOrder: number[] }) {
const data = useOutletContext<TournamentLoaderData>();
const [teamOrderInDb, setTeamOrderInDb] = React.useState(teamOrder);
const [showSuccess, setShowSuccess] = useTimeoutState(false);
const fetcher = useFetcher();
React.useEffect(() => {
// TODO: what if error?
if (fetcher.state !== "loading") return;
setTeamOrderInDb(teamOrder);
setShowSuccess(true, { timeout: 3000 });
// TODO: figure out a better way
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetcher.state]);
const teamOrderChanged = teamOrder.some((id, i) => id !== teamOrderInDb[i]);
return (
<fetcher.Form method="post" className="tournament__seeds__form">
<input type="hidden" name="tournamentId" value={data.event.id} />
<input type="hidden" name="seeds" value={JSON.stringify(teamOrder)} />
<Alert
variation={
teamOrderChanged ? "WARNING" : showSuccess ? "SUCCESS" : "INFO"
}
alertClassName="tournament-bracket__start-bracket-alert"
textClassName="stack horizontal md items-center"
>
{teamOrderChanged ? (
<>You have unchanged changes to seeding</>
) : showSuccess ? (
<>Seeds saved successfully!</>
) : (
<>Drag teams to adjust their seeding</>
)}
{(!showSuccess || teamOrderChanged) && (
<SubmitButton
state={fetcher.state}
disabled={!teamOrderChanged}
size="tiny"
>
Save seeds
</SubmitButton>
)}
</Alert>
</fetcher.Form>
);
}
function RowContents({
team,
seed,
}: {
team: TournamentLoaderTeam;
seed?: number;
}) {
const data = useLoaderData<typeof loader>();
return (
<>
<div>{seed}</div>
<div className="tournament__seeds__team-name">{team.name}</div>
<div className="stack horizontal sm">
{team.members.map((member) => {
const xPower = data.xPowers[member.userId];
const lonely =
(!xPower && member.plusTier) || (!member.plusTier && xPower);
return (
<div key={member.userId} className="tournament__seeds__team-member">
<div className="tournament__seeds__team-member__name">
{member.discordName}
</div>
{member.plusTier ? (
<div
className={clsx("stack horizontal items-center xxs", {
"add tournament__seeds__lonely-stat": lonely,
})}
>
<Image path={navIconUrl("plus")} alt="" width={16} /> +
{member.plusTier}
</div>
) : (
<div />
)}
{xPower ? (
<div
className={clsx("stack horizontal items-center xxs", {
"add tournament__seeds__lonely-stat": lonely,
})}
>
<Image path={navIconUrl("xsearch")} alt="" width={16} />{" "}
{xPower}
</div>
) : null}
</div>
);
})}
</div>
</>
);
}
export const CatchBoundary = Catcher;

View File

@ -1,17 +1,12 @@
import { Link, useOutletContext } from "@remix-run/react";
import { Avatar } from "~/components/Avatar";
import { AlertIcon } from "~/components/icons/Alert";
import { CheckmarkIcon } from "~/components/icons/Checkmark";
import { Image } from "~/components/Image";
import { useTranslation } from "~/hooks/useTranslation";
import { navIconUrl, userPage } from "~/utils/urls";
import type { FindTeamsByEventIdItem } from "../queries/findTeamsByEventId.server";
import { userPage } from "~/utils/urls";
import type { FindTeamsByTournamentIdItem } from "../queries/findTeamsByTournamentId.server";
import { TOURNAMENT } from "../tournament-constants";
import type { TournamentToolsLoaderData, TournamentToolsTeam } from "./to.$id";
import type { TournamentLoaderData, TournamentLoaderTeam } from "./to.$id";
export default function TournamentToolsTeamsPage() {
const { t } = useTranslation(["tournament"]);
const data = useOutletContext<TournamentToolsLoaderData>();
export default function TournamentTeamsPage() {
const data = useOutletContext<TournamentLoaderData>();
return (
<div className="stack lg">
@ -19,35 +14,7 @@ export default function TournamentToolsTeamsPage() {
.slice()
.sort(fullTeamAndHigherPlusStatusOnTop)
.map((team) => {
const hasMapPool = () => {
// before start empty array is returned if team has map list
// after start empty array means team has no map list
if (!data.mapListGeneratorAvailable) {
return Boolean(team.mapPool);
}
return team.mapPool && team.mapPool.length > 0;
};
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}
/>
{hasMapPool() ? (
<CheckmarkIcon className="fill-success" />
) : (
<AlertIcon className="fill-warning" />
)}
</div>
<TeamWithRoster team={team} />
</div>
);
return <TeamWithRoster key={team.id} team={team} />;
})}
</div>
);
@ -56,7 +23,7 @@ export default function TournamentToolsTeamsPage() {
function TeamWithRoster({
team,
}: {
team: Pick<FindTeamsByEventIdItem, "members" | "name">;
team: Pick<FindTeamsByTournamentIdItem, "members" | "name">;
}) {
return (
<div className="tournament__team-with-roster">
@ -82,8 +49,8 @@ function TeamWithRoster({
}
function fullTeamAndHigherPlusStatusOnTop(
teamA: TournamentToolsTeam,
teamB: TournamentToolsTeam
teamA: TournamentLoaderTeam,
teamB: TournamentLoaderTeam
) {
if (
teamA.members.length >= TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL &&

View File

@ -4,25 +4,46 @@ import type {
V2_MetaFunction,
SerializeFrom,
} from "@remix-run/node";
import { Outlet, useLoaderData } from "@remix-run/react";
import {
type ShouldRevalidateFunction,
Outlet,
useLoaderData,
useLocation,
} from "@remix-run/react";
import { Main } from "~/components/Main";
import { SubNav, SubNavLink } from "~/components/SubNav";
import { db } from "~/db";
import { useTranslation } from "~/hooks/useTranslation";
import { useUser } from "~/modules/auth";
import { getUserId } from "~/modules/auth/user.server";
import { canAdminCalendarTOTools } from "~/permissions";
import { canAdminTournament } from "~/permissions";
import { notFoundIfFalsy, type SendouRouteHandle } from "~/utils/remix";
import { makeTitle } from "~/utils/strings";
import type { Unpacked } from "~/utils/types";
import { findByIdentifier } from "../queries/findByIdentifier.server";
import type {
FindTeamsByEventId,
FindTeamsByEventIdItem,
} from "../queries/findTeamsByEventId.server";
import { findTeamsByEventId } from "../queries/findTeamsByEventId.server";
import { idFromParams } from "../tournament-utils";
import type { FindTeamsByTournamentId } from "../queries/findTeamsByTournamentId.server";
import { findTeamsByTournamentId } from "../queries/findTeamsByTournamentId.server";
import { tournamentIdFromParams } from "../tournament-utils";
import styles from "../tournament.css";
import hasTournamentStarted from "../queries/hasTournamentStarted.server";
export const shouldRevalidate: ShouldRevalidateFunction = (args) => {
const wasMutation = args.formMethod === "post";
const wasOnMatchPage = args.formAction?.includes("matches");
if (wasMutation && wasOnMatchPage) {
return false;
}
const wasRevalidation = !args.formMethod;
const wasOnBracketPage = args.currentUrl.href.includes("brackets");
if (wasRevalidation && wasOnBracketPage) {
return false;
}
return args.defaultShouldRevalidate;
};
export const meta: V2_MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader>;
@ -40,65 +61,84 @@ export const handle: SendouRouteHandle = {
i18n: ["tournament"],
};
export type TournamentToolsTeam = Unpacked<TournamentToolsLoaderData["teams"]>;
export type TournamentToolsLoaderData = SerializeFrom<typeof loader>;
export type TournamentLoaderTeam = Unpacked<TournamentLoaderData["teams"]>;
export type TournamentLoaderData = SerializeFrom<typeof loader>;
export const loader = async ({ params, request }: LoaderArgs) => {
const user = await getUserId(request);
const eventId = idFromParams(params);
const event = notFoundIfFalsy(findByIdentifier(eventId));
const tournamentId = tournamentIdFromParams(params);
const event = notFoundIfFalsy(findByIdentifier(tournamentId));
const mapListGeneratorAvailable =
canAdminCalendarTOTools({ user, event }) || !event.isBeforeStart;
canAdminTournament({ user, event }) || event.showMapListGenerator;
const teams = findTeamsByTournamentId(tournamentId);
const ownedTeamId = teams.find((team) =>
team.members.some((member) => member.userId === user?.id && member.isOwner)
)?.id;
const hasStarted = hasTournamentStarted(tournamentId);
return {
event,
tieBreakerMapPool:
db.calendarEvents.findTieBreakerMapPoolByEventId(eventId),
teams: censorMapPools(findTeamsByEventId(eventId)),
tieBreakerMapPool: db.calendarEvents.findTieBreakerMapPoolByEventId(
event.eventId
),
ownedTeamId,
teams: censorMapPools(teams),
mapListGeneratorAvailable,
hasStarted,
};
function censorMapPools(teams: FindTeamsByEventId): FindTeamsByEventId {
if (mapListGeneratorAvailable) return teams;
function censorMapPools(
teams: FindTeamsByTournamentId
): FindTeamsByTournamentId {
if (hasStarted || mapListGeneratorAvailable) return teams;
return teams.map((team) =>
team.members.some(
(member) => member.userId === user?.id && member.isOwner
)
team.id === ownedTeamId
? 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) > 0
? ([] as FindTeamsByEventIdItem["mapPool"])
: undefined,
mapPool: undefined,
}
);
}
};
export default function TournamentToolsLayout() {
// TODO: icons to nav could be nice
export default function TournamentLayout() {
const { t } = useTranslation(["tournament"]);
const user = useUser();
const data = useLoaderData<typeof loader>();
const location = useLocation();
const onBracketsPage = location.pathname.includes("brackets");
return (
<Main>
<Main bigger={onBracketsPage}>
<SubNav>
{data.event.isBeforeStart ? (
<SubNavLink to="register">{t("tournament:tabs.register")}</SubNavLink>
{!data.hasStarted ? (
<SubNavLink to="register" data-testid="register-tab">
{t("tournament:tabs.register")}
</SubNavLink>
) : null}
<SubNavLink to="brackets" data-testid="brackets-tab">
Brackets
</SubNavLink>
{data.mapListGeneratorAvailable ? (
<SubNavLink to="maps">{t("tournament:tabs.maps")}</SubNavLink>
) : null}
<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>
{canAdminTournament({ user, event: data.event }) &&
!data.hasStarted && <SubNavLink to="seeds">Seeds</SubNavLink>}
{canAdminTournament({ user, event: data.event }) && (
<SubNavLink to="admin" data-testid="admin-tab">
{t("tournament:tabs.admin")}
</SubNavLink>
)}
</SubNav>
<Outlet context={data} />

View File

@ -1,5 +1,5 @@
export const TOURNAMENT = {
TEAM_NAME_MAX_LENGTH: 64,
TEAM_NAME_MAX_LENGTH: 32,
COUNTERPICK_MAPS_PER_MODE: 2,
COUNTERPICK_MAX_STAGE_REPEAT: 2,
COUNTERPICK_ONE_MODE_TOURNAMENT_MAPS_PER_MODE: 6,

View File

@ -2,12 +2,12 @@ import { useOutletContext } from "@remix-run/react";
import * as React from "react";
import { useUser } from "~/modules/auth";
import type { RankedModeShort, StageId } from "~/modules/in-game-lists";
import type { TournamentToolsLoaderData } from "./routes/to.$id";
import type { TournamentLoaderData } from "./routes/to.$id";
import { mapPickCountPerMode, resolveOwnedTeam } from "./tournament-utils";
export function useSelectCounterpickMapPoolState() {
const user = useUser();
const parentRouteData = useOutletContext<TournamentToolsLoaderData>();
const parentRouteData = useOutletContext<TournamentLoaderData>();
const resolveInitialMapPool = (mode: RankedModeShort) => {
const ownMapPool =

View File

@ -1,11 +1,12 @@
import { z } from "zod";
import { id } from "~/utils/zod";
import { checkboxValueToBoolean, id, safeJSONParse } from "~/utils/zod";
import { TOURNAMENT } from "./tournament-constants";
export const registerSchema = z.union([
z.object({
_action: z.literal("UPSERT_TEAM"),
teamName: z.string().min(1).max(TOURNAMENT.TEAM_NAME_MAX_LENGTH),
prefersNotToHost: z.preprocess(checkboxValueToBoolean, z.boolean()),
}),
z.object({
_action: z.literal("UPDATE_MAP_POOL"),
@ -15,4 +16,53 @@ export const registerSchema = z.union([
_action: z.literal("DELETE_TEAM_MEMBER"),
userId: id,
}),
z.object({
_action: z.literal("CHECK_IN"),
}),
z.object({
_action: z.literal("ADD_PLAYER"),
userId: id,
}),
]);
export const seedsActionSchema = z.object({
seeds: z.preprocess(safeJSONParse, z.array(id)),
});
export const adminActionSchema = z.union([
z.object({
_action: z.literal("UPDATE_SHOW_MAP_LIST_GENERATOR"),
show: z.preprocess(checkboxValueToBoolean, z.boolean()),
}),
z.object({
_action: z.literal("CHANGE_TEAM_OWNER"),
teamId: id,
memberId: id,
}),
z.object({
_action: z.literal("CHECK_IN"),
teamId: id,
}),
z.object({
_action: z.literal("CHECK_OUT"),
teamId: id,
}),
z.object({
_action: z.literal("ADD_MEMBER"),
teamId: id,
"user[value]": id,
}),
z.object({
_action: z.literal("REMOVE_MEMBER"),
teamId: id,
memberId: id,
}),
z.object({
_action: z.literal("DELETE_TEAM"),
teamId: id,
}),
]);
export const joinSchema = z.object({
trust: z.preprocess(checkboxValueToBoolean, z.boolean()),
});

View File

@ -1,18 +1,22 @@
import type { Params } from "@remix-run/react";
import invariant from "tiny-invariant";
import type { User } from "~/db/types";
import type { FindTeamsByEventId } from "./queries/findTeamsByEventId.server";
import type { TournamentToolsLoaderData } from "./routes/to.$id";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import type { Tournament, User } from "~/db/types";
import type { ModeShort } from "~/modules/in-game-lists";
import { TOURNAMENT } from "./tournament-constants";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import { databaseTimestampToDate } from "~/utils/dates";
import type { FindTeamsByTournamentId } from "./queries/findTeamsByTournamentId.server";
import type {
TournamentLoaderData,
TournamentLoaderTeam,
} from "./routes/to.$id";
import { TOURNAMENT } from "./tournament-constants";
import { validate } from "~/utils/remix";
export function resolveOwnedTeam({
teams,
userId,
}: {
teams: FindTeamsByEventId;
teams: Array<TournamentLoaderTeam>;
userId?: User["id"];
}) {
if (typeof userId !== "number") return;
@ -22,7 +26,13 @@ export function resolveOwnedTeam({
);
}
export function idFromParams(params: Params<string>) {
export function teamHasCheckedIn(
team: Pick<TournamentLoaderTeam, "checkedInAt">
) {
return Boolean(team.checkedInAt);
}
export function tournamentIdFromParams(params: Params<string>) {
const result = Number(params["id"]);
invariant(!Number.isNaN(result), "id is not a number");
@ -30,24 +40,36 @@ export function idFromParams(params: Params<string>) {
}
export function modesIncluded(
event: TournamentToolsLoaderData["event"]
tournament: Pick<Tournament, "mapPickingStyle">
): ModeShort[] {
if (event.toToolsMode) return [event.toToolsMode];
return [...rankedModesShort];
switch (tournament.mapPickingStyle) {
case "AUTO_SZ": {
return ["SZ"];
}
case "AUTO_TC": {
return ["TC"];
}
case "AUTO_RM": {
return ["RM"];
}
case "AUTO_CB": {
return ["CB"];
}
default: {
return [...rankedModesShort];
}
}
}
export function isOneModeTournamentOf(
event: TournamentToolsLoaderData["event"]
tournament: Pick<Tournament, "mapPickingStyle">
) {
if (event.toToolsMode) return event.toToolsMode;
return null;
return modesIncluded(tournament).length === 1
? modesIncluded(tournament)[0]!
: null;
}
export function HACKY_resolvePicture(
event: TournamentToolsLoaderData["event"]
) {
export function HACKY_resolvePicture(event: TournamentLoaderData["event"]) {
if (event.name.includes("In The Zone"))
return "https://abload.de/img/screenshot2023-04-19a2bfv0.png";
@ -57,13 +79,39 @@ export function HACKY_resolvePicture(
// hacky because db query not taking in account possibility of many start times
// AND always assumed check-in starts 1h before
export function HACKY_resolveCheckInTime(
event: TournamentToolsLoaderData["event"]
event: Pick<TournamentLoaderData["event"], "startTime">
) {
return databaseTimestampToDate(event.startTime - 60 * 60);
}
export function mapPickCountPerMode(event: TournamentToolsLoaderData["event"]) {
export function mapPickCountPerMode(event: TournamentLoaderData["event"]) {
return isOneModeTournamentOf(event)
? TOURNAMENT.COUNTERPICK_ONE_MODE_TOURNAMENT_MAPS_PER_MODE
: TOURNAMENT.COUNTERPICK_MAPS_PER_MODE;
}
export function checkInHasStarted(
event: Pick<TournamentLoaderData["event"], "startTime">
) {
return HACKY_resolveCheckInTime(event).getTime() < Date.now();
}
export function validateCanCheckIn({
event,
team,
}: {
event: Pick<TournamentLoaderData["event"], "startTime">;
team: FindTeamsByTournamentId[number];
}) {
validate(checkInHasStarted(event), "Check-in has not started yet");
validate(
team.members.length >= TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL,
"Team does not have enough members"
);
validate(
team.mapPool && team.mapPool.length > 0,
"Team does not have a map pool"
);
return true;
}

View File

@ -134,17 +134,6 @@
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;
}
.tournament__logo-container {
display: flex;
align-items: center;
@ -204,6 +193,10 @@
white-space: nowrap;
}
.tournament__section__icon {
width: 2rem;
}
.tournament__missing-player {
width: 62px;
height: 62px;
@ -214,6 +207,10 @@
place-items: center;
}
.tournament__missing-player__optional {
border: 2px dashed var(--theme-transparent);
}
.tournament__invite-container {
margin-block-start: var(--s-14);
display: flex;
@ -222,6 +219,88 @@
align-items: center;
}
.tournament__seeds__form {
width: 100%;
display: flex;
align-items: center;
}
.tournament__seeds__order-button {
margin-block-start: var(--s-2);
margin-inline-end: auto;
}
/* TODO: overflow-x scroll */
.tournament__seeds__teams-list-row {
display: grid;
width: 100%;
align-items: center;
padding: var(--s-1-5) var(--s-3);
border-radius: var(--rounded);
column-gap: var(--s-1);
font-size: var(--fonts-xs);
grid-template-columns: 3rem 8rem 1fr;
list-style: none;
row-gap: var(--s-1-5);
}
.tournament__seeds__teams-list-row.sortable:not(.disabled) {
cursor: grab;
}
.tournament__seeds__teams-list-row.sortable:hover:not(.disabled)
.tournament__seeds__team-member {
background-color: var(--bg-lighter-transparent);
}
.tournament__seeds__teams-list-row.active .tournament__seeds__team-member {
background-color: var(--bg-lighter-transparent);
}
.tournament__seeds__teams-list-row.active {
cursor: grabbing;
}
.tournament__seeds__teams-list-row.sortable:active:not(.disabled) {
cursor: grabbing !important;
}
.tournament__seeds__teams-container__header {
font-weight: var(--bold);
}
.tournament__seeds__team-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tournament__seeds__team-member {
display: grid;
grid-template-columns: max-content max-content;
grid-column-gap: var(--s-2-5);
background-color: var(--bg-lighter);
border-radius: var(--rounded);
padding: var(--s-1) var(--s-3);
place-items: center;
}
.tournament__seeds__team-member__name {
grid-column: 1 / span 2;
font-weight: var(--semi-bold);
}
.tournament__seeds__lonely-stat {
grid-column: 1 / span 2;
}
.tournament__seeds__plus-info {
display: inline-flex;
align-items: center;
margin-inline-end: var(--s-4);
min-width: 2rem;
}
@media screen and (min-width: 640px) {
.tournament__section {
margin: 0;

View File

@ -0,0 +1,16 @@
import * as React from "react";
/** Forces the component to rerender every second */
export function useAutoRerender() {
const [, setNow] = React.useState(new Date().getTime());
React.useEffect(() => {
const interval = setInterval(() => {
setNow(new Date().getTime());
}, 1000);
return () => {
clearInterval(interval);
};
}, []);
}

View File

@ -0,0 +1,33 @@
import * as React from "react";
// TODO: fix causes memory leak
/** @link https://stackoverflow.com/a/64983274 */
export const useTimeoutState = <T>(
defaultState: T
): [
T,
(action: React.SetStateAction<T>, opts?: { timeout: number }) => void
] => {
const [state, _setState] = React.useState<T>(defaultState);
const [currentTimeoutId, setCurrentTimeoutId] = React.useState<
NodeJS.Timeout | undefined
>();
const setState = React.useCallback(
(action: React.SetStateAction<T>, opts?: { timeout: number }) => {
if (currentTimeoutId != null) {
clearTimeout(currentTimeoutId);
}
_setState(action);
const id = setTimeout(
() => _setState(defaultState),
opts?.timeout ?? 4000
);
setCurrentTimeoutId(id);
},
[currentTimeoutId, defaultState]
);
return [state, setState];
};

View File

@ -0,0 +1 @@
Taken from https://github.com/Drarig29/brackets-manager.js

View File

@ -0,0 +1,667 @@
import type { DeepPartial, Storage } from "../types";
import type {
Group,
Match,
MatchGame,
Round,
SeedOrdering,
Stage,
StageType,
GroupType,
} from "brackets-model";
import type { RoundPositionalInfo } from "../types";
import type { Create } from "../create";
import * as helpers from "../helpers";
export class BaseGetter {
protected readonly storage: Storage;
/**
* Creates an instance of a Storage getter.
*
* @param storage The implementation of Storage.
*/
constructor(storage: Storage) {
this.storage = storage;
}
/**
* Gets all the rounds that contain ordered participants.
*
* @param stage The stage to get rounds from.
*/
protected getOrderedRounds(stage: Stage): Round[] {
if (!stage?.settings.size) throw Error("The stage has no size.");
if (stage.type === "single_elimination")
return this.getOrderedRoundsSingleElimination(stage.id);
return this.getOrderedRoundsDoubleElimination(stage.id);
}
/**
* Gets all the rounds that contain ordered participants in a single elimination stage.
*
* @param stageId ID of the stage.
*/
private getOrderedRoundsSingleElimination(stageId: number): Round[] {
return [this.getUpperBracketFirstRound(stageId)];
}
/**
* Gets all the rounds that contain ordered participants in a double elimination stage.
*
* @param stageId ID of the stage.
*/
private getOrderedRoundsDoubleElimination(stageId: number): Round[] {
// Getting all rounds instead of cherry-picking them is the least expensive.
const rounds = this.storage.select("round", { stage_id: stageId });
if (!rounds) throw Error("Error getting rounds.");
const loserBracket = this.getLoserBracket(stageId);
if (!loserBracket) throw Error("Loser bracket not found.");
const firstRoundWB = rounds[0];
const roundsLB = rounds.filter((r) => r.group_id === loserBracket.id);
const orderedRoundsLB = roundsLB.filter((r) =>
helpers.isOrderingSupportedLoserBracket(r.number, roundsLB.length)
);
return [firstRoundWB, ...orderedRoundsLB];
}
/**
* Gets the positional information (number in group and total number of rounds in group) of a round based on its id.
*
* @param roundId ID of the round.
*/
protected getRoundPositionalInfo(roundId: number): RoundPositionalInfo {
const round = this.storage.select("round", roundId);
if (!round) throw Error("Round not found.");
const rounds = this.storage.select("round", {
group_id: round.group_id,
});
if (!rounds) throw Error("Error getting rounds.");
return {
roundNumber: round.number,
roundCount: rounds.length,
};
}
/**
* Gets the matches leading to the given match.
*
* @param match The current match.
* @param matchLocation Location of the current match.
* @param stage The parent stage.
* @param roundNumber Number of the round.
*/
protected getPreviousMatches(
match: Match,
matchLocation: GroupType,
stage: Stage,
roundNumber: number
): Match[] {
if (matchLocation === "loser_bracket")
return this.getPreviousMatchesLB(match, stage, roundNumber);
if (matchLocation === "final_group")
return this.getPreviousMatchesFinal(match, roundNumber);
if (roundNumber === 1) return []; // The match is in the first round of an upper bracket.
return this.getMatchesBeforeMajorRound(match, roundNumber);
}
/**
* Gets the matches leading to the given match, which is in a final group (consolation final or grand final).
*
* @param match The current match.
* @param roundNumber Number of the current round.
*/
private getPreviousMatchesFinal(match: Match, roundNumber: number): Match[] {
if (roundNumber > 1)
return [this.findMatch(match.group_id, roundNumber - 1, 1)];
const upperBracket = this.getUpperBracket(match.stage_id);
const lastRound = this.getLastRound(upperBracket.id);
const upperBracketFinalMatch = this.storage.selectFirst("match", {
round_id: lastRound.id,
number: 1,
});
if (upperBracketFinalMatch === null) throw Error("Match not found.");
return [upperBracketFinalMatch];
}
/**
* Gets the matches leading to a given match from the loser bracket.
*
* @param match The current match.
* @param stage The parent stage.
* @param roundNumber Number of the round.
*/
private getPreviousMatchesLB(
match: Match,
stage: Stage,
roundNumber: number
): Match[] {
if (stage.settings.skipFirstRound && roundNumber === 1) return [];
if (helpers.hasBye(match)) return []; // Shortcut because we are coming from propagateByes().
const winnerBracket = this.getUpperBracket(match.stage_id);
const actualRoundNumberWB = Math.ceil((roundNumber + 1) / 2);
const roundNumberWB = stage.settings.skipFirstRound
? actualRoundNumberWB - 1
: actualRoundNumberWB;
if (roundNumber === 1)
return this.getMatchesBeforeFirstRoundLB(
match,
winnerBracket.id,
roundNumberWB
);
if (roundNumber % 2 === 0)
return this.getMatchesBeforeMinorRoundLB(
match,
winnerBracket.id,
roundNumber,
roundNumberWB
);
return this.getMatchesBeforeMajorRound(match, roundNumber);
}
/**
* Gets the matches leading to a given match in a major round (every round of upper bracket or specific ones in lower bracket).
*
* @param match The current match.
* @param roundNumber Number of the round.
*/
private getMatchesBeforeMajorRound(
match: Match,
roundNumber: number
): Match[] {
return [
this.findMatch(match.group_id, roundNumber - 1, match.number * 2 - 1),
this.findMatch(match.group_id, roundNumber - 1, match.number * 2),
];
}
/**
* Gets the matches leading to a given match in the first round of the loser bracket.
*
* @param match The current match.
* @param winnerBracketId ID of the winner bracket.
* @param roundNumberWB The number of the previous round in the winner bracket.
*/
private getMatchesBeforeFirstRoundLB(
match: Match,
winnerBracketId: number,
roundNumberWB: number
): Match[] {
return [
this.findMatch(
winnerBracketId,
roundNumberWB,
helpers.getOriginPosition(match, "opponent1")
),
this.findMatch(
winnerBracketId,
roundNumberWB,
helpers.getOriginPosition(match, "opponent2")
),
];
}
/**
* Gets the matches leading to a given match in a minor round of the loser bracket.
*
* @param match The current match.
* @param winnerBracketId ID of the winner bracket.
* @param roundNumber Number of the current round.
* @param roundNumberWB The number of the previous round in the winner bracket.
*/
private getMatchesBeforeMinorRoundLB(
match: Match,
winnerBracketId: number,
roundNumber: number,
roundNumberWB: number
): Match[] {
const matchNumber = helpers.getOriginPosition(match, "opponent1");
return [
this.findMatch(winnerBracketId, roundNumberWB, matchNumber),
this.findMatch(match.group_id, roundNumber - 1, match.number),
];
}
/**
* Gets the match(es) where the opponents of the current match will go just after.
*
* @param match The current match.
* @param matchLocation Location of the current match.
* @param stage The parent stage.
* @param roundNumber The number of the current round.
* @param roundCount Count of rounds.
*/
protected getNextMatches(
match: Match,
matchLocation: GroupType,
stage: Stage,
roundNumber: number,
roundCount: number
): (Match | null)[] {
switch (matchLocation) {
case "single_bracket":
return this.getNextMatchesUpperBracket(
match,
stage.type,
roundNumber,
roundCount
);
case "winner_bracket":
return this.getNextMatchesWB(match, stage, roundNumber, roundCount);
case "loser_bracket":
return this.getNextMatchesLB(
match,
stage.type,
roundNumber,
roundCount
);
case "final_group":
return this.getNextMatchesFinal(match, roundNumber, roundCount);
default:
throw Error("Unknown bracket kind.");
}
}
/**
* Gets the match(es) where the opponents of the current match of winner bracket will go just after.
*
* @param match The current match.
* @param stage The parent stage.
* @param roundNumber The number of the current round.
* @param roundCount Count of rounds.
*/
private getNextMatchesWB(
match: Match,
stage: Stage,
roundNumber: number,
roundCount: number
): (Match | null)[] {
const loserBracket = this.getLoserBracket(match.stage_id);
if (loserBracket === null)
// Only one match in the stage, there is no loser bracket.
return [];
const actualRoundNumber = stage.settings.skipFirstRound
? roundNumber + 1
: roundNumber;
const roundNumberLB =
actualRoundNumber > 1 ? (actualRoundNumber - 1) * 2 : 1;
const participantCount = stage.settings.size!;
const method = helpers.getLoserOrdering(
stage.settings.seedOrdering!,
roundNumberLB
);
const actualMatchNumberLB = helpers.findLoserMatchNumber(
participantCount,
roundNumberLB,
match.number,
method
);
return [
...this.getNextMatchesUpperBracket(
match,
stage.type,
roundNumber,
roundCount
),
this.findMatch(loserBracket.id, roundNumberLB, actualMatchNumberLB),
];
}
/**
* Gets the match(es) where the opponents of the current match of an upper bracket will go just after.
*
* @param match The current match.
* @param stageType Type of the stage.
* @param roundNumber The number of the current round.
* @param roundCount Count of rounds.
*/
private getNextMatchesUpperBracket(
match: Match,
stageType: StageType,
roundNumber: number,
roundCount: number
): (Match | null)[] {
if (stageType === "single_elimination")
return this.getNextMatchesUpperBracketSingleElimination(
match,
stageType,
roundNumber,
roundCount
);
if (stageType === "double_elimination" && roundNumber === roundCount)
return [this.getFirstMatchFinal(match, stageType)];
return [this.getDiagonalMatch(match.group_id, roundNumber, match.number)];
}
/**
* Gets the match(es) where the opponents of the current match of the unique bracket of a single elimination will go just after.
*
* @param match The current match.
* @param stageType Type of the stage.
* @param roundNumber The number of the current round.
* @param roundCount Count of rounds.
*/
private getNextMatchesUpperBracketSingleElimination(
match: Match,
stageType: StageType,
roundNumber: number,
roundCount: number
): Match[] {
if (roundNumber === roundCount - 1) {
const final = this.getFirstMatchFinal(match, stageType);
return [
this.getDiagonalMatch(match.group_id, roundNumber, match.number),
...(final ? [final] : []),
];
}
if (roundNumber === roundCount) return [];
return [this.getDiagonalMatch(match.group_id, roundNumber, match.number)];
}
/**
* Gets the match(es) where the opponents of the current match of loser bracket will go just after.
*
* @param match The current match.
* @param stageType Type of the stage.
* @param roundNumber The number of the current round.
* @param roundCount Count of rounds.
*/
private getNextMatchesLB(
match: Match,
stageType: StageType,
roundNumber: number,
roundCount: number
): Match[] {
if (roundNumber === roundCount) {
const final = this.getFirstMatchFinal(match, stageType);
return final ? [final] : [];
}
if (roundNumber % 2 === 1)
return this.getMatchAfterMajorRoundLB(match, roundNumber);
return this.getMatchAfterMinorRoundLB(match, roundNumber);
}
/**
* Gets the first match of the final group (consolation final or grand final).
*
* @param match The current match.
* @param stageType Type of the stage.
*/
private getFirstMatchFinal(match: Match, stageType: StageType): Match | null {
const finalGroupId = this.getFinalGroupId(match.stage_id, stageType);
if (finalGroupId === null) return null;
return this.findMatch(finalGroupId, 1, 1);
}
/**
* Gets the matches following the current match, which is in the final group (consolation final or grand final).
*
* @param match The current match.
* @param roundNumber The number of the current round.
* @param roundCount The count of rounds.
*/
private getNextMatchesFinal(
match: Match,
roundNumber: number,
roundCount: number
): Match[] {
if (roundNumber === roundCount) return [];
return [this.findMatch(match.group_id, roundNumber + 1, 1)];
}
/**
* Gets the match(es) where the opponents of the current match of a winner bracket's major round will go just after.
*
* @param match The current match.
* @param roundNumber The number of the current round.
*/
private getMatchAfterMajorRoundLB(
match: Match,
roundNumber: number
): Match[] {
return [this.getParallelMatch(match.group_id, roundNumber, match.number)];
}
/**
* Gets the match(es) where the opponents of the current match of a winner bracket's minor round will go just after.
*
* @param match The current match.
* @param roundNumber The number of the current round.
*/
private getMatchAfterMinorRoundLB(
match: Match,
roundNumber: number
): Match[] {
return [this.getDiagonalMatch(match.group_id, roundNumber, match.number)];
}
/**
* Returns the good seeding ordering based on the stage's type.
*
* @param stageType The type of the stage.
* @param create A reference to a Create instance.
*/
protected static getSeedingOrdering(
stageType: StageType,
create: Create
): SeedOrdering {
return stageType === "round_robin"
? create.getRoundRobinOrdering()
: create.getStandardBracketFirstRoundOrdering();
}
/**
* Returns the matches which contain the seeding of a stage based on its type.
*
* @param stageId ID of the stage.
* @param stageType The type of the stage.
*/
protected getSeedingMatches(
stageId: number,
stageType: StageType
): Match[] | null {
if (stageType === "round_robin")
return this.storage.select("match", { stage_id: stageId });
const firstRound = this.getUpperBracketFirstRound(stageId);
return this.storage.select("match", { round_id: firstRound.id });
}
/**
* Gets the first round of the upper bracket.
*
* @param stageId ID of the stage.
*/
private getUpperBracketFirstRound(stageId: number): Round {
// Considering the database is ordered, this round will always be the first round of the upper bracket.
const firstRound = this.storage.selectFirst("round", {
stage_id: stageId,
number: 1,
});
if (!firstRound) throw Error("Round not found.");
return firstRound;
}
/**
* Gets the last round of a group.
*
* @param groupId ID of the group.
*/
private getLastRound(groupId: number): Round {
const round = this.storage.selectLast("round", { group_id: groupId });
if (!round) throw Error("Error getting rounds.");
return round;
}
/**
* Returns the id of the final group (consolation final or grand final).
*
* @param stageId ID of the stage.
* @param stageType Type of the stage.
*/
private getFinalGroupId(
stageId: number,
stageType: StageType
): number | null {
const groupNumber =
stageType === "single_elimination"
? 2 /* Consolation final */
: 3; /* Grand final */
const finalGroup = this.storage.selectFirst("group", {
stage_id: stageId,
number: groupNumber,
});
if (!finalGroup) return null;
return finalGroup.id;
}
/**
* Gets the upper bracket (the only bracket if single elimination or the winner bracket in double elimination).
*
* @param stageId ID of the stage.
*/
protected getUpperBracket(stageId: number): Group {
const winnerBracket = this.storage.selectFirst("group", {
stage_id: stageId,
number: 1,
});
if (!winnerBracket) throw Error("Winner bracket not found.");
return winnerBracket;
}
/**
* Gets the loser bracket.
*
* @param stageId ID of the stage.
*/
protected getLoserBracket(stageId: number): Group | null {
return this.storage.selectFirst("group", { stage_id: stageId, number: 2 });
}
/**
* Gets the corresponding match in the next round ("diagonal match") the usual way.
*
* Just like from Round 1 to Round 2 in a single elimination stage.
*
* @param groupId ID of the group.
* @param roundNumber Number of the round in its parent group.
* @param matchNumber Number of the match in its parent round.
*/
private getDiagonalMatch(
groupId: number,
roundNumber: number,
matchNumber: number
): Match {
return this.findMatch(
groupId,
roundNumber + 1,
helpers.getDiagonalMatchNumber(matchNumber)
);
}
/**
* Gets the corresponding match in the next round ("parallel match") the "major round to minor round" way.
*
* Just like from Round 1 to Round 2 in the loser bracket of a double elimination stage.
*
* @param groupId ID of the group.
* @param roundNumber Number of the round in its parent group.
* @param matchNumber Number of the match in its parent round.
*/
private getParallelMatch(
groupId: number,
roundNumber: number,
matchNumber: number
): Match {
return this.findMatch(groupId, roundNumber + 1, matchNumber);
}
/**
* Finds a match in a given group. The match must have the given number in a round of which the number in group is given.
*
* **Example:** In group of id 1, give me the 4th match in the 3rd round.
*
* @param groupId ID of the group.
* @param roundNumber Number of the round in its parent group.
* @param matchNumber Number of the match in its parent round.
*/
protected findMatch(
groupId: number,
roundNumber: number,
matchNumber: number
): Match {
const round = this.storage.selectFirst("round", {
group_id: groupId,
number: roundNumber,
});
if (!round) throw Error("Round not found.");
const match = this.storage.selectFirst("match", {
round_id: round.id,
number: matchNumber,
});
if (!match) throw Error("Match not found.");
return match;
}
/**
* Finds a match game based on its `id` or based on the combination of its `parent_id` and `number`.
*
* @param game Values to change in a match game.
*/
protected findMatchGame(game: DeepPartial<MatchGame>): MatchGame {
if (game.id !== undefined) {
const stored = this.storage.select("match_game", game.id);
if (!stored) throw Error("Match game not found.");
return stored;
}
if (game.parent_id !== undefined && game.number) {
const stored = this.storage.selectFirst("match_game", {
parent_id: game.parent_id,
number: game.number,
});
if (!stored) throw Error("Match game not found.");
return stored;
}
throw Error("No match game id nor parent id and number given.");
}
}

View File

@ -0,0 +1,436 @@
import type {
Match,
MatchGame,
Seeding,
Stage,
GroupType,
} from "brackets-model";
import { Status } from "brackets-model";
import type { DeepPartial, ParticipantSlot, Side } from "../types";
import type { SetNextOpponent } from "../helpers";
import { ordering } from "../ordering";
import { Create } from "../create";
import { BaseGetter } from "./getter";
import { Get } from "../get";
import * as helpers from "../helpers";
export class BaseUpdater extends BaseGetter {
/**
* Updates or resets the seeding of a stage.
*
* @param stageId ID of the stage.
* @param seeding A new seeding or `null` to reset the existing seeding.
*/
protected updateSeeding(stageId: number, seeding: Seeding | null): void {
const stage = this.storage.select("stage", stageId);
if (!stage) throw Error("Stage not found.");
const create = new Create(this.storage, {
name: stage.name,
tournamentId: stage.tournament_id,
type: stage.type,
settings: stage.settings,
seeding: seeding || undefined,
});
create.setExisting(stageId, false);
const method = BaseGetter.getSeedingOrdering(stage.type, create);
const slots = create.getSlots();
const matches = this.getSeedingMatches(stage.id, stage.type);
if (!matches)
throw Error("Error getting matches associated to the seeding.");
const ordered = ordering[method](slots);
BaseUpdater.assertCanUpdateSeeding(matches, ordered);
create.run();
}
/**
* Confirms the current seeding of a stage.
*
* @param stageId ID of the stage.
*/
protected confirmCurrentSeeding(stageId: number): void {
const stage = this.storage.select("stage", stageId);
if (!stage) throw Error("Stage not found.");
const get = new Get(this.storage);
const currentSeeding = get.seeding(stageId);
const newSeeding = helpers.convertSlotsToSeeding(
currentSeeding.map(helpers.convertTBDtoBYE)
);
const create = new Create(this.storage, {
name: stage.name,
tournamentId: stage.tournament_id,
type: stage.type,
settings: stage.settings,
seeding: newSeeding,
});
create.setExisting(stageId, true);
create.run();
}
/**
* Updates a parent match based on its child games.
*
* @param parentId ID of the parent match.
* @param inRoundRobin Indicates whether the parent match is in a round-robin stage.
*/
protected updateParentMatch(parentId: number, inRoundRobin: boolean): void {
const storedParent = this.storage.select("match", parentId);
if (!storedParent) throw Error("Parent not found.");
const games = this.storage.select("match_game", {
parent_id: parentId,
});
if (!games) throw Error("No match games.");
const parentScores = helpers.getChildGamesResults(games);
const parent = helpers.getParentMatchResults(storedParent, parentScores);
helpers.setParentMatchCompleted(
parent,
storedParent.child_count,
inRoundRobin
);
this.updateMatch(storedParent, parent, true);
}
/**
* Throws an error if a match is locked and the new seeding will change this match's participants.
*
* @param matches The matches stored in the database.
* @param slots The slots to check from the new seeding.
*/
protected static assertCanUpdateSeeding(
matches: Match[],
slots: ParticipantSlot[]
): void {
let index = 0;
for (const match of matches) {
const opponent1 = slots[index++];
const opponent2 = slots[index++];
const locked = helpers.isMatchParticipantLocked(match);
if (!locked) continue;
if (
match.opponent1?.id !== opponent1?.id ||
match.opponent2?.id !== opponent2?.id
)
throw Error("A match is locked.");
}
}
/**
* Updates the matches related (previous and next) to a match.
*
* @param match A match.
* @param updatePrevious Whether to update the previous matches.
* @param updateNext Whether to update the next matches.
*/
protected updateRelatedMatches(
match: Match,
updatePrevious: boolean,
updateNext: boolean
): void {
const { roundNumber, roundCount } = this.getRoundPositionalInfo(
match.round_id
);
const stage = this.storage.select("stage", match.stage_id);
if (!stage) throw Error("Stage not found.");
const group = this.storage.select("group", match.group_id);
if (!group) throw Error("Group not found.");
const matchLocation = helpers.getMatchLocation(stage.type, group.number);
updatePrevious &&
this.updatePrevious(match, matchLocation, stage, roundNumber);
updateNext &&
this.updateNext(match, matchLocation, stage, roundNumber, roundCount);
}
/**
* Updates a match based on a partial match.
*
* @param stored A reference to what will be updated in the storage.
* @param match Input of the update.
* @param force Whether to force update locked matches.
*/
protected updateMatch(
stored: Match,
match: DeepPartial<Match>,
force?: boolean
): void {
if (!force && helpers.isMatchUpdateLocked(stored))
throw Error("The match is locked.");
const stage = this.storage.select("stage", stored.stage_id);
if (!stage) throw Error("Stage not found.");
const inRoundRobin = helpers.isRoundRobin(stage);
const { statusChanged, resultChanged } = helpers.setMatchResults(
stored,
match,
inRoundRobin
);
this.applyMatchUpdate(stored);
// Don't update related matches if it's a simple score update.
if (!statusChanged && !resultChanged) return;
if (!helpers.isRoundRobin(stage))
this.updateRelatedMatches(stored, statusChanged, resultChanged);
}
/**
* Updates a match game based on a partial match game.
*
* @param stored A reference to what will be updated in the storage.
* @param game Input of the update.
*/
protected updateMatchGame(
stored: MatchGame,
game: DeepPartial<MatchGame>
): void {
if (helpers.isMatchUpdateLocked(stored))
throw Error("The match game is locked.");
const stage = this.storage.select("stage", stored.stage_id);
if (!stage) throw Error("Stage not found.");
const inRoundRobin = helpers.isRoundRobin(stage);
helpers.setMatchResults(stored, game, inRoundRobin);
if (!this.storage.update("match_game", stored.id, stored))
throw Error("Could not update the match game.");
this.updateParentMatch(stored.parent_id, inRoundRobin);
}
/**
* Updates the opponents and status of a match and its child games.
*
* @param match A match.
*/
protected applyMatchUpdate(match: Match): void {
if (!this.storage.update("match", match.id, match))
throw Error("Could not update the match.");
if (match.child_count === 0) return;
const updatedMatchGame: Partial<MatchGame> = {
opponent1: helpers.toResult(match.opponent1),
opponent2: helpers.toResult(match.opponent2),
};
// Only sync the child games' status with their parent's status when changing the parent match participants
// (Locked, Waiting, Ready) or when archiving the parent match.
if (match.status <= Status.Ready || match.status === Status.Archived)
updatedMatchGame.status = match.status;
if (
!this.storage.update(
"match_game",
{ parent_id: match.id },
updatedMatchGame
)
)
throw Error("Could not update the match game.");
}
/**
* Updates the match(es) leading to the current match based on this match results.
*
* @param match Input of the update.
* @param matchLocation Location of the current match.
* @param stage The parent stage.
* @param roundNumber Number of the round.
*/
protected updatePrevious(
match: Match,
matchLocation: GroupType,
stage: Stage,
roundNumber: number
): void {
const previousMatches = this.getPreviousMatches(
match,
matchLocation,
stage,
roundNumber
);
if (previousMatches.length === 0) return;
if (match.status >= Status.Running) this.archiveMatches(previousMatches);
else this.resetMatchesStatus(previousMatches);
}
/**
* Sets the status of a list of matches to archived.
*
* @param matches The matches to update.
*/
protected archiveMatches(matches: Match[]): void {
for (const match of matches) {
match.status = Status.Archived;
this.applyMatchUpdate(match);
}
}
/**
* Resets the status of a list of matches to what it should currently be.
*
* @param matches The matches to update.
*/
protected resetMatchesStatus(matches: Match[]): void {
for (const match of matches) {
match.status = helpers.getMatchStatus(match);
this.applyMatchUpdate(match);
}
}
/**
* Updates the match(es) following the current match based on this match results.
*
* @param match Input of the update.
* @param matchLocation Location of the current match.
* @param stage The parent stage.
* @param roundNumber Number of the round.
* @param roundCount Count of rounds.
*/
protected updateNext(
match: Match,
matchLocation: GroupType,
stage: Stage,
roundNumber: number,
roundCount: number
): void {
const nextMatches = this.getNextMatches(
match,
matchLocation,
stage,
roundNumber,
roundCount
);
if (nextMatches.length === 0) return;
const winnerSide = helpers.getMatchResult(match);
const actualRoundNumber =
stage.settings.skipFirstRound && matchLocation === "winner_bracket"
? roundNumber + 1
: roundNumber;
if (winnerSide)
this.applyToNextMatches(
helpers.setNextOpponent,
match,
matchLocation,
actualRoundNumber,
roundCount,
nextMatches,
winnerSide
);
else
this.applyToNextMatches(
helpers.resetNextOpponent,
match,
matchLocation,
actualRoundNumber,
roundCount,
nextMatches
);
}
/**
* Applies a SetNextOpponent function to matches following the current match.
*
* @param setNextOpponent The SetNextOpponent function.
* @param match The current match.
* @param matchLocation Location of the current match.
* @param roundNumber Number of the current round.
* @param roundCount Count of rounds.
* @param nextMatches The matches following the current match.
* @param winnerSide Side of the winner in the current match.
*/
protected applyToNextMatches(
setNextOpponent: SetNextOpponent,
match: Match,
matchLocation: GroupType,
roundNumber: number,
roundCount: number,
nextMatches: (Match | null)[],
winnerSide?: Side
): void {
if (matchLocation === "final_group") {
if (!nextMatches[0]) throw Error("First next match is null.");
setNextOpponent(nextMatches[0], "opponent1", match, "opponent1");
setNextOpponent(nextMatches[0], "opponent2", match, "opponent2");
this.applyMatchUpdate(nextMatches[0]);
return;
}
const nextSide = helpers.getNextSide(
match.number,
roundNumber,
roundCount,
matchLocation
);
if (nextMatches[0]) {
setNextOpponent(nextMatches[0], nextSide, match, winnerSide);
this.propagateByeWinners(nextMatches[0]);
}
if (nextMatches.length !== 2) return;
if (!nextMatches[1]) throw Error("Second next match is null.");
// The second match is either the consolation final (single elimination) or a loser bracket match (double elimination).
if (matchLocation === "single_bracket") {
setNextOpponent(
nextMatches[1],
nextSide,
match,
winnerSide && helpers.getOtherSide(winnerSide)
);
this.applyMatchUpdate(nextMatches[1]);
} else {
const nextSideLB = helpers.getNextSideLoserBracket(
match.number,
nextMatches[1],
roundNumber
);
setNextOpponent(
nextMatches[1],
nextSideLB,
match,
winnerSide && helpers.getOtherSide(winnerSide)
);
this.propagateByeWinners(nextMatches[1]);
}
}
/**
* Propagates winner against BYEs in related matches.
*
* @param match The current match.
*/
protected propagateByeWinners(match: Match): void {
helpers.setMatchResults(match, match, false); // BYE propagation is only in non round-robin stages.
this.applyMatchUpdate(match);
if (helpers.hasBye(match)) this.updateRelatedMatches(match, true, true);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,59 @@
import type { Storage } from "./types";
export class Delete {
private readonly storage: Storage;
/**
* Creates an instance of Delete, which will handle cleanly deleting data in the storage.
*
* @param storage The implementation of Storage.
*/
constructor(storage: Storage) {
this.storage = storage;
}
/**
* Deletes a stage, and all its components:
*
* - Groups
* - Rounds
* - Matches
* - Match games
*
* @param stageId ID of the stage.
*/
public stage(stageId: number): void {
// The order is important here, because the abstract storage can possibly have foreign key checks (e.g. SQL).
if (!this.storage.delete("match_game", { stage_id: stageId }))
throw Error("Could not delete match games.");
if (!this.storage.delete("match", { stage_id: stageId }))
throw Error("Could not delete matches.");
if (!this.storage.delete("round", { stage_id: stageId }))
throw Error("Could not delete rounds.");
if (!this.storage.delete("group", { stage_id: stageId }))
throw Error("Could not delete groups.");
if (!this.storage.delete("stage", { id: stageId }))
throw Error("Could not delete the stage.");
}
/**
* Deletes **the stages** of a tournament (and all their components, see {@link stage | delete.stage()}).
*
* You are responsible for deleting the tournament itself.
*
* @param tournamentId ID of the tournament.
*/
public tournament(tournamentId: number): void {
const stages = this.storage.select("stage", {
tournament_id: tournamentId,
});
if (!stages) throw Error("Error getting the stages.");
for (const stage of stages) this.stage(stage.id);
}
}

View File

@ -0,0 +1,164 @@
import type { Group, Match, MatchGame } from "brackets-model";
import { BaseGetter } from "./base/getter";
import * as helpers from "./helpers";
export class Find extends BaseGetter {
/**
* Gets the upper bracket (the only bracket if single elimination or the winner bracket in double elimination).
*
* @param stageId ID of the stage.
*/
public upperBracket(stageId: number): Group {
const stage = this.storage.select("stage", stageId);
if (!stage) throw Error("Stage not found.");
switch (stage.type) {
case "round_robin":
throw Error("Round-robin stages do not have an upper bracket.");
case "single_elimination":
case "double_elimination":
return this.getUpperBracket(stageId);
default:
throw Error("Unknown stage type.");
}
}
/**
* Gets the loser bracket.
*
* @param stageId ID of the stage.
*/
public loserBracket(stageId: number): Group {
const stage = this.storage.select("stage", stageId);
if (!stage) throw Error("Stage not found.");
switch (stage.type) {
case "round_robin":
throw Error("Round-robin stages do not have a loser bracket.");
case "single_elimination":
throw Error("Single elimination stages do not have a loser bracket.");
case "double_elimination":
// eslint-disable-next-line no-case-declarations
const group = this.getLoserBracket(stageId);
if (!group) throw Error("Loser bracket not found.");
return group;
default:
throw Error("Unknown stage type.");
}
}
/**
* Returns the matches leading to the given match.
*
* If a `participantId` is given, the previous match _from their point of view_ is returned.
*
* @param matchId ID of the target match.
* @param participantId Optional ID of the participant.
*/
public previousMatches(matchId: number, participantId?: number): Match[] {
const match = this.storage.select("match", matchId);
if (!match) throw Error("Match not found.");
const stage = this.storage.select("stage", match.stage_id);
if (!stage) throw Error("Stage not found.");
const group = this.storage.select("group", match.group_id);
if (!group) throw Error("Group not found.");
const round = this.storage.select("round", match.round_id);
if (!round) throw Error("Round not found.");
const matchLocation = helpers.getMatchLocation(stage.type, group.number);
const previousMatches = this.getPreviousMatches(
match,
matchLocation,
stage,
round.number
);
if (participantId !== undefined)
return previousMatches.filter((m) =>
helpers.isParticipantInMatch(m, participantId)
);
return previousMatches;
}
/**
* Returns the matches following the given match.
*
* If a `participantId` is given:
* - If the participant won, the next match _from their point of view_ is returned.
* - If the participant is eliminated, no match is returned.
*
* @param matchId ID of the target match.
* @param participantId Optional ID of the participant.
*/
public nextMatches(matchId: number, participantId?: number): Match[] {
const match = this.storage.select("match", matchId);
if (!match) throw Error("Match not found.");
const stage = this.storage.select("stage", match.stage_id);
if (!stage) throw Error("Stage not found.");
const group = this.storage.select("group", match.group_id);
if (!group) throw Error("Group not found.");
const { roundNumber, roundCount } = this.getRoundPositionalInfo(
match.round_id
);
const matchLocation = helpers.getMatchLocation(stage.type, group.number);
const nextMatches = helpers.getNonNull(
this.getNextMatches(match, matchLocation, stage, roundNumber, roundCount)
);
if (participantId !== undefined) {
const loser = helpers.getLoser(match);
if (stage.type === "single_elimination" && loser?.id === participantId)
return []; // Eliminated.
if (stage.type === "double_elimination") {
const [upperBracketMatch, lowerBracketMatch] = nextMatches;
if (loser?.id === participantId) {
if (lowerBracketMatch) return [lowerBracketMatch];
else return []; // Eliminated from lower bracket.
}
const winner = helpers.getWinner(match);
if (winner?.id === participantId) return [upperBracketMatch];
throw Error("The participant does not belong to this match.");
}
}
return nextMatches;
}
/**
* Finds a match in a given group. The match must have the given number in a round of which the number in group is given.
*
* **Example:** In group of id 1, give me the 4th match in the 3rd round.
*
* @param groupId ID of the group.
* @param roundNumber Number of the round in its parent group.
* @param matchNumber Number of the match in its parent round.
*/
public match(
groupId: number,
roundNumber: number,
matchNumber: number
): Match {
return this.findMatch(groupId, roundNumber, matchNumber);
}
/**
* Finds a match game based on its `id` or based on the combination of its `parent_id` and `number`.
*
* @param game Values to change in a match game.
*/
public matchGame(game: Partial<MatchGame>): MatchGame {
return this.findMatchGame(game);
}
}

View File

@ -0,0 +1,473 @@
import type {
Stage,
Group,
Round,
Match,
MatchGame,
Participant,
} from "brackets-model";
import { Status } from "brackets-model";
import type { Database, FinalStandingsItem, ParticipantSlot } from "./types";
import { BaseGetter } from "./base/getter";
import * as helpers from "./helpers";
export class Get extends BaseGetter {
/**
* Returns the data needed to display a stage.
*
* @param stageId ID of the stage.
*/
public stageData(stageId: number): Database {
const stageData = this.getStageSpecificData(stageId);
const participants = this.storage.select("participant", {
tournament_id: stageData.stage.tournament_id,
});
if (!participants) throw Error("Error getting participants.");
return {
stage: [stageData.stage],
group: stageData.groups,
round: stageData.rounds,
match: stageData.matches,
match_game: stageData.matchGames,
participant: participants,
};
}
/**
* Returns the data needed to display a whole tournament with all its stages.
*
* @param tournamentId ID of the tournament.
*/
public tournamentData(tournamentId: number): Database {
const stages = this.storage.select("stage", {
tournament_id: tournamentId,
});
if (!stages) throw Error("Error getting stages.");
const stagesData = stages.map((stage) =>
this.getStageSpecificData(stage.id)
);
const participants = this.storage.select("participant", {
tournament_id: tournamentId,
});
if (!participants) throw Error("Error getting participants.");
return {
stage: stages,
group: stagesData.reduce(
(acc, data) => [...acc, ...data.groups],
[] as Group[]
),
round: stagesData.reduce(
(acc, data) => [...acc, ...data.rounds],
[] as Round[]
),
match: stagesData.reduce(
(acc, data) => [...acc, ...data.matches],
[] as Match[]
),
match_game: stagesData.reduce(
(acc, data) => [...acc, ...data.matchGames],
[] as MatchGame[]
),
participant: participants,
};
}
/**
* Returns the match games associated to a list of matches.
*
* @param matches A list of matches.
*/
public matchGames(matches: Match[]): MatchGame[] {
const parentMatches = matches.filter((match) => match.child_count > 0);
const matchGamesQueries = parentMatches.map((match) =>
this.storage.select("match_game", { parent_id: match.id })
);
if (matchGamesQueries.some((game) => game === null))
throw Error("Error getting match games.");
return helpers.getNonNull(matchGamesQueries).flat();
}
/**
* Returns the stage that is not completed yet, because of uncompleted matches.
* If all matches are completed in this tournament, there is no "current stage", so `null` is returned.
*
* @param tournamentId ID of the tournament.
*/
public currentStage(tournamentId: number): Stage | null {
const stages = this.storage.select("stage", {
tournament_id: tournamentId,
});
if (!stages) throw Error("Error getting stages.");
for (const stage of stages) {
const matches = this.storage.select("match", {
stage_id: stage.id,
});
if (!matches) throw Error("Error getting matches.");
if (matches.every((match) => match.status >= Status.Completed)) continue;
return stage;
}
return null;
}
/**
* Returns the round that is not completed yet, because of uncompleted matches.
* If all matches are completed in this stage of a tournament, there is no "current round", so `null` is returned.
*
* Note: The consolation final of single elimination and the grand final of double elimination will be in a different `Group`.
*
* @param stageId ID of the stage.
* @example
* If you don't know the stage id, you can first get the current stage.
* ```js
* const tournamentId = 3;
* const currentStage = manager.get.currentStage(tournamentId);
* const currentRound = manager.get.currentRound(currentStage.id);
* ```
*/
public currentRound(stageId: number): Round | null {
const matches = this.storage.select("match", { stage_id: stageId });
if (!matches) throw Error("Error getting matches.");
const matchesByRound = helpers.splitBy(matches, "round_id");
for (const roundMatches of matchesByRound) {
if (roundMatches.every((match) => match.status >= Status.Completed))
continue;
const round = this.storage.select("round", roundMatches[0].round_id);
if (!round) throw Error("Round not found.");
return round;
}
return null;
}
/**
* Returns the matches that can currently be played in parallel.
* If all matches are completed in this stage of a tournament, an empty array is returned.
*
* Note: Completed matches are also returned.
*
* @param stageId ID of the stage.
* @example
* If you don't know the stage id, you can first get the current stage.
* ```js
* const tournamentId = 3;
* const currentStage = manager.get.currentStage(tournamentId);
* const currentMatches = manager.get.currentMatches(currentStage.id);
* ```
*/
public currentMatches(stageId: number): Match[] {
const stage = this.storage.select("stage", stageId);
if (!stage) throw Error("Stage not found.");
// TODO: Implement this for all stage types.
// - For round robin, 1 round per group can be played in parallel at their own pace.
// - For double elimination, 1 round per bracket (upper and lower) can be played in parallel at their own pace.
if (stage.type !== "single_elimination")
throw Error(
"Not implemented for round robin and double elimination. Ask if needed."
);
const matches = this.storage.select("match", { stage_id: stageId });
if (!matches) throw Error("Error getting matches.");
const matchesByRound = helpers.splitBy(matches, "round_id");
const roundCount = helpers.getUpperBracketRoundCount(stage.settings.size!);
// Save multiple queries for `round`.
let currentRoundIndex = -1;
for (const roundMatches of matchesByRound) {
currentRoundIndex++;
if (
stage.settings.consolationFinal &&
currentRoundIndex === roundCount - 1
) {
// We are on the final of the single elimination.
const [final] = roundMatches;
const [consolationFinal] = matchesByRound[currentRoundIndex + 1];
const finals = [final, consolationFinal];
if (finals.every((match) => match.status >= Status.Completed))
return [];
return finals;
}
if (roundMatches.every((match) => match.status >= Status.Completed))
continue;
return roundMatches;
}
return [];
}
/**
* Returns the seeding of a stage.
*
* @param stageId ID of the stage.
*/
public seeding(stageId: number): ParticipantSlot[] {
const stage = this.storage.select("stage", stageId);
if (!stage) throw Error("Stage not found.");
const pickRelevantProps = (slot: ParticipantSlot): ParticipantSlot => {
if (slot === null) return null;
const { id, position } = slot;
return { id, position };
};
if (stage.type === "round_robin")
return this.roundRobinSeeding(stage).map(pickRelevantProps);
return this.eliminationSeeding(stage).map(pickRelevantProps);
}
/**
* Returns the final standings of a stage.
*
* @param stageId ID of the stage.
*/
public finalStandings(stageId: number): FinalStandingsItem[] {
const stage = this.storage.select("stage", stageId);
if (!stage) throw Error("Stage not found.");
switch (stage.type) {
case "round_robin":
throw Error("A round-robin stage does not have standings.");
case "single_elimination":
return this.singleEliminationStandings(stageId);
case "double_elimination":
return this.doubleEliminationStandings(stageId);
default:
throw Error("Unknown stage type.");
}
}
/**
* Returns the seeding of a round-robin stage.
*
* @param stage The stage.
*/
private roundRobinSeeding(stage: Stage): ParticipantSlot[] {
if (stage.settings.size === undefined)
throw Error("The size of the seeding is undefined.");
const matches = this.storage.select("match", { stage_id: stage.id });
if (!matches) throw Error("Error getting matches.");
const slots = helpers.convertMatchesToSeeding(matches);
// BYE vs. BYE matches of a round-robin stage are removed
// when the stage is created. We need to add them back temporarily.
if (slots.length < stage.settings.size) {
const diff = stage.settings.size - slots.length;
for (let i = 0; i < diff; i++) slots.push(null);
}
const unique = helpers.uniqueBy(slots, (item) => item && item.position);
const seeding = helpers.setArraySize(unique, stage.settings.size, null);
return seeding;
}
/**
* Returns the seeding of an elimination stage.
*
* @param stage The stage.
*/
private eliminationSeeding(stage: Stage): ParticipantSlot[] {
const round = this.storage.selectFirst("round", {
stage_id: stage.id,
number: 1,
});
if (!round) throw Error("Error getting the first round.");
const matches = this.storage.select("match", { round_id: round.id });
if (!matches) throw Error("Error getting matches.");
return helpers.convertMatchesToSeeding(matches);
}
/**
* Returns the final standings of a single elimination stage.
*
* @param stageId ID of the stage.
*/
private singleEliminationStandings(stageId: number): FinalStandingsItem[] {
const grouped: Participant[][] = [];
const {
stage: stages,
group: groups,
match: matches,
participant: participants,
} = this.stageData(stageId);
const [stage] = stages;
const [singleBracket, finalGroup] = groups;
const final = matches
.filter((match) => match.group_id === singleBracket.id)
.pop();
if (!final) throw Error("Final not found.");
// 1st place: Final winner.
grouped[0] = [
helpers.findParticipant(participants, getFinalWinnerIfDefined(final)),
];
// Rest: every loser in reverse order.
const losers = helpers.getLosers(
participants,
matches.filter((match) => match.group_id === singleBracket.id)
);
grouped.push(...losers.reverse());
if (stage.settings?.consolationFinal) {
const consolationFinal = matches
.filter((match) => match.group_id === finalGroup.id)
.pop();
if (!consolationFinal) throw Error("Consolation final not found.");
const consolationFinalWinner = helpers.findParticipant(
participants,
getFinalWinnerIfDefined(consolationFinal)
);
const consolationFinalLoser = helpers.findParticipant(
participants,
helpers.getLoser(consolationFinal)
);
// Overwrite semi-final losers with the consolation final results.
grouped.splice(2, 1, [consolationFinalWinner], [consolationFinalLoser]);
}
return helpers.makeFinalStandings(grouped);
}
/**
* Returns the final standings of a double elimination stage.
*
* @param stageId ID of the stage.
*/
private doubleEliminationStandings(stageId: number): FinalStandingsItem[] {
const grouped: Participant[][] = [];
const {
stage: stages,
group: groups,
match: matches,
participant: participants,
} = this.stageData(stageId);
const [stage] = stages;
const [winnerBracket, loserBracket, finalGroup] = groups;
if (stage.settings?.grandFinal === "none") {
const finalWB = matches
.filter((match) => match.group_id === winnerBracket.id)
.pop();
if (!finalWB) throw Error("WB final not found.");
const finalLB = matches
.filter((match) => match.group_id === loserBracket.id)
.pop();
if (!finalLB) throw Error("LB final not found.");
// 1st place: WB Final winner.
grouped[0] = [
helpers.findParticipant(participants, getFinalWinnerIfDefined(finalWB)),
];
// 2nd place: LB Final winner.
grouped[1] = [
helpers.findParticipant(participants, getFinalWinnerIfDefined(finalLB)),
];
} else {
const grandFinalMatches = matches.filter(
(match) => match.group_id === finalGroup.id
);
const decisiveMatch = helpers.getGrandFinalDecisiveMatch(
stage.settings?.grandFinal || "none",
grandFinalMatches
);
// 1st place: Grand Final winner.
grouped[0] = [
helpers.findParticipant(
participants,
getFinalWinnerIfDefined(decisiveMatch)
),
];
// 2nd place: Grand Final loser.
grouped[1] = [
helpers.findParticipant(participants, helpers.getLoser(decisiveMatch)),
];
}
// Rest: every loser in reverse order.
const losers = helpers.getLosers(
participants,
matches.filter((match) => match.group_id === loserBracket.id)
);
grouped.push(...losers.reverse());
return helpers.makeFinalStandings(grouped);
}
/**
* Returns only the data specific to the given stage (without the participants).
*
* @param stageId ID of the stage.
*/
private getStageSpecificData(stageId: number): {
stage: Stage;
groups: Group[];
rounds: Round[];
matches: Match[];
matchGames: MatchGame[];
} {
const stage = this.storage.select("stage", stageId);
if (!stage) throw Error("Stage not found.");
const groups = this.storage.select("group", { stage_id: stageId });
if (!groups) throw Error("Error getting groups.");
const rounds = this.storage.select("round", { stage_id: stageId });
if (!rounds) throw Error("Error getting rounds.");
const matches = this.storage.select("match", { stage_id: stageId });
if (!matches) throw Error("Error getting matches.");
const matchGames = this.matchGames(matches);
return {
stage,
groups,
rounds,
matches,
matchGames,
};
}
}
const getFinalWinnerIfDefined = (match: Match): ParticipantSlot => {
const winner = helpers.getWinner(match);
if (!winner) throw Error("The final match does not have a winner.");
return winner;
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,32 @@
import { BracketsManager } from "./manager";
import * as helpers from "./helpers";
import type {
CrudInterface,
Database,
Duel,
OmitId,
OrderingMap,
ParticipantSlot,
Scores,
Side,
StandardBracketResults,
Storage,
Table,
} from "./types";
export {
BracketsManager,
CrudInterface,
Database,
Duel,
OmitId,
OrderingMap,
ParticipantSlot,
Scores,
Side,
StandardBracketResults,
Storage,
Table,
helpers,
};

View File

@ -0,0 +1,142 @@
import type {
CrudInterface,
Database,
DataTypes,
Storage,
Table,
} from "./types";
import type { InputStage, Stage } from "brackets-model";
import { create } from "./create";
import { Get } from "./get";
import { Update } from "./update";
import { Delete } from "./delete";
import { Find } from "./find";
import { Reset } from "./reset";
import * as helpers from "./helpers";
/**
* A class to handle tournament management at those levels: `stage`, `group`, `round`, `match` and `match_game`.
*/
export class BracketsManager {
public storage: Storage;
public get: Get;
public update: Update;
public delete: Delete;
public find: Find;
public reset: Reset;
/**
* Creates an instance of BracketsManager, which will handle all the stuff from the library.
*
* @param storageInterface An implementation of CrudInterface.
*/
constructor(storageInterface: CrudInterface) {
const storage = storageInterface as Storage;
storage.selectFirst = <T extends Table>(
table: T,
filter: Partial<DataTypes[T]>
): DataTypes[T] | null => {
const results = this.storage.select<T>(table, filter);
if (!results || results.length === 0) return null;
return results[0];
};
storage.selectLast = <T extends Table>(
table: T,
filter: Partial<DataTypes[T]>
): DataTypes[T] | null => {
const results = this.storage.select<T>(table, filter);
if (!results || results.length === 0) return null;
return results[results.length - 1];
};
this.storage = storage;
this.get = new Get(this.storage);
this.update = new Update(this.storage);
this.delete = new Delete(this.storage);
this.find = new Find(this.storage);
this.reset = new Reset(this.storage);
}
/**
* Creates a stage for an existing tournament. The tournament won't be created.
*
* @param stage A stage to create.
*/
public create(stage: InputStage): Stage {
return create.call(this, stage);
}
/**
* Imports data in the database.
*
* @param data Data to import.
* @param normalizeIds Enable ID normalization: all IDs (and references to them) are remapped to consecutive IDs starting from 0.
*/
public import(data: Database, normalizeIds = false): void {
if (normalizeIds) data = helpers.normalizeIds(data);
if (!this.storage.delete("participant"))
throw Error("Could not empty the participant table.");
if (!this.storage.insert("participant", data.participant))
throw Error("Could not import participants.");
if (!this.storage.delete("stage"))
throw Error("Could not empty the stage table.");
if (!this.storage.insert("stage", data.stage))
throw Error("Could not import stages.");
if (!this.storage.delete("group"))
throw Error("Could not empty the group table.");
if (!this.storage.insert("group", data.group))
throw Error("Could not import groups.");
if (!this.storage.delete("round"))
throw Error("Could not empty the round table.");
if (!this.storage.insert("round", data.round))
throw Error("Could not import rounds.");
if (!this.storage.delete("match"))
throw Error("Could not empty the match table.");
if (!this.storage.insert("match", data.match))
throw Error("Could not import matches.");
if (!this.storage.delete("match_game"))
throw Error("Could not empty the match_game table.");
if (!this.storage.insert("match_game", data.match_game))
throw Error("Could not import match games.");
}
/**
* Exports data from the database.
*/
public export(): Database {
const participants = this.storage.select("participant");
if (!participants) throw Error("Error getting participants.");
const stages = this.storage.select("stage");
if (!stages) throw Error("Error getting stages.");
const groups = this.storage.select("group");
if (!groups) throw Error("Error getting groups.");
const rounds = this.storage.select("round");
if (!rounds) throw Error("Error getting rounds.");
const matches = this.storage.select("match");
if (!matches) throw Error("Error getting matches.");
const matchGames = this.get.matchGames(matches);
return {
participant: participants,
stage: stages,
group: groups,
round: rounds,
match: matches,
match_game: matchGames,
};
}
}

View File

@ -0,0 +1,114 @@
// https://web.archive.org/web/20200601102344/https://tl.net/forum/sc2-tournaments/202139-superior-double-elimination-losers-bracket-seeding
import type { SeedOrdering } from "brackets-model";
import type { OrderingMap } from "./types";
export const ordering: OrderingMap = {
natural: <T>(array: T[]) => [...array],
reverse: <T>(array: T[]) => [...array].reverse(),
half_shift: <T>(array: T[]) => [
...array.slice(array.length / 2),
...array.slice(0, array.length / 2),
],
reverse_half_shift: <T>(array: T[]) => [
...array.slice(0, array.length / 2).reverse(),
...array.slice(array.length / 2).reverse(),
],
pair_flip: <T>(array: T[]) => {
const result: T[] = [];
for (let i = 0; i < array.length; i += 2)
result.push(array[i + 1], array[i]);
return result;
},
inner_outer: <T>(array: T[]) => {
if (array.length === 2) return array;
const size = array.length / 4;
const innerPart = [
array.slice(size, 2 * size),
array.slice(2 * size, 3 * size),
]; // [_, X, X, _]
const outerPart = [array.slice(0, size), array.slice(3 * size, 4 * size)]; // [X, _, _, X]
const methods = {
inner(part: T[][]): T[] {
return [part[0].pop()!, part[1].shift()!];
},
outer(part: T[][]): T[] {
return [part[0].shift()!, part[1].pop()!];
},
};
const result: T[] = [];
/**
* Adds a part (inner or outer) of a part.
*
* @param part The part to process.
* @param method The method to use.
*/
function add(part: T[][], method: "inner" | "outer"): void {
if (part[0].length > 0 && part[1].length > 0)
result.push(...methods[method](part));
}
for (let i = 0; i < size / 2; i++) {
add(outerPart, "outer"); // Outer part's outer
add(innerPart, "inner"); // Inner part's inner
add(outerPart, "inner"); // Outer part's inner
add(innerPart, "outer"); // Inner part's outer
}
return result;
},
"groups.effort_balanced": <T>(array: T[], groupCount: number) => {
const result: T[] = [];
let i = 0,
j = 0;
while (result.length < array.length) {
result.push(array[i]);
i += groupCount;
if (i >= array.length) i = ++j;
}
return result;
},
"groups.seed_optimized": <T>(array: T[], groupCount: number) => {
const groups = Array.from(Array(groupCount), (_): T[] => []);
for (let run = 0; run < array.length / groupCount; run++) {
if (run % 2 === 0) {
for (let group = 0; group < groupCount; group++)
groups[group].push(array[run * groupCount + group]);
} else {
for (let group = 0; group < groupCount; group++)
groups[groupCount - group - 1].push(array[run * groupCount + group]);
}
}
return groups.flat();
},
"groups.bracket_optimized": () => {
throw Error("Not implemented.");
},
};
export const defaultMinorOrdering: { [key: number]: SeedOrdering[] } = {
// 1 or 2: Not possible.
4: ["natural", "reverse"],
8: ["natural", "reverse", "natural"],
16: ["natural", "reverse_half_shift", "reverse", "natural"],
32: ["natural", "reverse", "half_shift", "natural", "natural"],
64: ["natural", "reverse", "half_shift", "reverse", "natural", "natural"],
128: [
"natural",
"reverse",
"half_shift",
"pair_flip",
"pair_flip",
"pair_flip",
"natural",
],
};

View File

@ -0,0 +1,98 @@
import { Status } from "brackets-model";
import { BaseUpdater } from "./base/updater";
import * as helpers from "./helpers";
export class Reset extends BaseUpdater {
/**
* Resets the results of a match.
*
* This will update related matches accordingly.
*
* @param matchId ID of the match.
*/
public matchResults(matchId: number): void {
const stored = this.storage.select("match", matchId);
if (!stored) throw Error("Match not found.");
// The user can handle forfeits with matches which have child games in two possible ways:
//
// 1. Set forfeits for the parent match directly.
// --> The child games will never be updated: not locked, not finished, without forfeit. They will just be ignored and never be played.
// --> To reset the forfeits, the user has to reset the parent match, with `reset.matchResults()`.
// --> `reset.matchResults()` will be usable **only** to reset the forfeit of the parent match. Otherwise it will throw the error below.
//
// 2. Set forfeits for each child game.
// --> The parent match won't automatically have a forfeit, but will be updated with a computed score according to the forfeited match games.
// --> To reset the forfeits, the user has to reset each child game on its own, with `reset.matchGameResults()`.
// --> `reset.matchResults()` will throw the error below in all cases.
if (!helpers.isMatchForfeitCompleted(stored) && stored.child_count > 0)
throw Error(
"The parent match is controlled by its child games and its result cannot be reset."
);
const stage = this.storage.select("stage", stored.stage_id);
if (!stage) throw Error("Stage not found.");
const group = this.storage.select("group", stored.group_id);
if (!group) throw Error("Group not found.");
const { roundNumber, roundCount } = this.getRoundPositionalInfo(
stored.round_id
);
const matchLocation = helpers.getMatchLocation(stage.type, group.number);
const nextMatches = this.getNextMatches(
stored,
matchLocation,
stage,
roundNumber,
roundCount
);
if (
nextMatches.some(
(match) =>
match &&
match.status >= Status.Running &&
!helpers.isMatchByeCompleted(match)
)
)
throw Error("The match is locked.");
helpers.resetMatchResults(stored);
this.applyMatchUpdate(stored);
if (!helpers.isRoundRobin(stage))
this.updateRelatedMatches(stored, true, true);
}
/**
* Resets the results of a match game.
*
* @param gameId ID of the match game.
*/
public matchGameResults(gameId: number): void {
const stored = this.storage.select("match_game", gameId);
if (!stored) throw Error("Match game not found.");
const stage = this.storage.select("stage", stored.stage_id);
if (!stage) throw Error("Stage not found.");
const inRoundRobin = helpers.isRoundRobin(stage);
helpers.resetMatchResults(stored);
if (!this.storage.update("match_game", stored.id, stored))
throw Error("Could not update the match game.");
this.updateParentMatch(stored.parent_id, inRoundRobin);
}
/**
* Resets the seeding of a stage.
*
* @param stageId ID of the stage.
*/
public seeding(stageId: number): void {
this.updateSeeding(stageId, null);
}
}

View File

@ -0,0 +1,230 @@
import type {
Group,
Match,
MatchGame,
Participant,
Round,
SeedOrdering,
Stage,
} from "brackets-model";
/**
* Type of an object implementing every ordering method.
*/
export type OrderingMap = Record<
SeedOrdering,
<T>(array: T[], ...args: number[]) => T[]
>;
/**
* Omits the `id` property of a type.
*/
export type OmitId<T> = Omit<T, "id">;
/**
* Defines a T which can be null.
*/
export type Nullable<T> = T | null;
/**
* An object which maps an ID to another ID.
*/
export type IdMapping = Record<number, number>;
/**
* Used by the library to handle placements. Is `null` if is a BYE. Has a `null` name if it's yet to be determined.
*/
export type ParticipantSlot = { id: number | null; position?: number } | null;
/**
* The library only handles duels. It's one participant versus another participant.
*/
export type Duel = [ParticipantSlot, ParticipantSlot];
/**
* The side of an opponent.
*/
export type Side = "opponent1" | "opponent2";
/**
* The cumulated scores of the opponents in a match's child games.
*/
export type Scores = { opponent1: number; opponent2: number };
/**
* The possible levels of data to which we can update the child games count.
*/
export type ChildCountLevel = "stage" | "group" | "round" | "match";
/**
* Positional information about a round.
*/
export type RoundPositionalInfo = {
roundNumber: number;
roundCount: number;
};
/**
* The result of an array which was split by parity.
*/
export interface ParitySplit<T> {
even: T[];
odd: T[];
}
/**
* Makes an object type deeply partial.
*/
export type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
}
: T;
/**
* Converts all value types to array types.
*/
type ValueToArray<T> = {
[K in keyof T]: Array<T[K]>;
};
/**
* Data type associated to each database table.
*/
export interface DataTypes {
stage: Stage;
group: Group;
round: Round;
match: Match;
match_game: MatchGame;
participant: Participant;
}
/**
* The types of table in the storage.
*/
export type Table = keyof DataTypes;
/**
* Format of the data in a database.
*/
export type Database = ValueToArray<DataTypes>;
/**
* An item in the final standings of an elimination stage.
*/
export interface FinalStandingsItem {
id: number;
name: string;
rank: number;
}
/**
* Contains the losers and the winner of the bracket.
*/
export interface StandardBracketResults {
/**
* The list of losers for each round of the bracket.
*/
losers: ParticipantSlot[][];
/**
* The winner of the bracket.
*/
winner: ParticipantSlot;
}
/**
* This CRUD interface is used by the manager to abstract storage.
*/
export interface CrudInterface {
/**
* Inserts a value in the database and returns its id.
*
* @param table Where to insert.
* @param value What to insert.
*/
insert<T extends Table>(table: T, value: OmitId<DataTypes[T]>): number;
/**
* Inserts multiple values in the database.
*
* @param table Where to insert.
* @param values What to insert.
*/
insert<T extends Table>(table: T, values: OmitId<DataTypes[T]>[]): boolean;
/**
* Gets all data from a table in the database.
*
* @param table Where to get from.
*/
select<T extends Table>(table: T): Array<DataTypes[T]> | null;
/**
* Gets specific data from a table in the database.
*
* @param table Where to get from.
* @param id What to get.
*/
select<T extends Table>(table: T, id: number): DataTypes[T] | null;
/**
* Gets data from a table in the database with a filter.
*
* @param table Where to get from.
* @param filter An object to filter data.
*/
select<T extends Table>(
table: T,
filter: Partial<DataTypes[T]>
): Array<DataTypes[T]> | null;
/**
* Updates data in a table.
*
* @param table Where to update.
* @param id What to update.
* @param value How to update.
*/
update<T extends Table>(table: T, id: number, value: DataTypes[T]): boolean;
/**
* Updates data in a table.
*
* @param table Where to update.
* @param filter An object to filter data.
* @param value How to update.
*/
update<T extends Table>(
table: T,
filter: Partial<DataTypes[T]>,
value: Partial<DataTypes[T]>
): boolean;
/**
* Empties a table completely.
*
* @param table Where to delete everything.
*/
delete<T extends Table>(table: T): boolean;
/**
* Delete data in a table, based on a filter.
*
* @param table Where to delete in.
* @param filter An object to filter data.
*/
delete<T extends Table>(table: T, filter: Partial<DataTypes[T]>): boolean;
}
export interface Storage extends CrudInterface {
selectFirst<T extends Table>(
table: T,
filter: Partial<DataTypes[T]>
): DataTypes[T] | null;
selectLast<T extends Table>(
table: T,
filter: Partial<DataTypes[T]>
): DataTypes[T] | null;
}

View File

@ -0,0 +1,319 @@
import type {
Match,
MatchGame,
Round,
Seeding,
SeedOrdering,
} from "brackets-model";
import { Status } from "brackets-model";
import { ordering } from "./ordering";
import { BaseUpdater } from "./base/updater";
import type { ChildCountLevel, DeepPartial } from "./types";
import * as helpers from "./helpers";
export class Update extends BaseUpdater {
/**
* Updates partial information of a match. Its id must be given.
*
* This will update related matches accordingly.
*
* @param match Values to change in a match.
*/
public match<M extends Match = Match>(match: DeepPartial<M>): void {
if (match.id === undefined) throw Error("No match id given.");
const stored = this.storage.select("match", match.id);
if (!stored) throw Error("Match not found.");
this.updateMatch(stored, match);
}
/**
* Updates partial information of a match game. Its id must be given.
*
* This will update the parent match accordingly.
*
* @param game Values to change in a match game.
*/
public matchGame<G extends MatchGame = MatchGame>(
game: DeepPartial<G>
): void {
const stored = this.findMatchGame(game);
this.updateMatchGame(stored, game);
}
/**
* Updates the seed ordering of every ordered round in a stage.
*
* @param stageId ID of the stage.
* @param seedOrdering A list of ordering methods.
*/
public ordering(stageId: number, seedOrdering: SeedOrdering[]): void {
const stage = this.storage.select("stage", stageId);
if (!stage) throw Error("Stage not found.");
helpers.ensureNotRoundRobin(stage);
const roundsToOrder = this.getOrderedRounds(stage);
if (seedOrdering.length !== roundsToOrder.length)
throw Error("The count of seed orderings is incorrect.");
for (let i = 0; i < roundsToOrder.length; i++)
this.updateRoundOrdering(roundsToOrder[i], seedOrdering[i]);
}
/**
* Updates the seed ordering of a round.
*
* @param roundId ID of the round.
* @param method Seed ordering method.
*/
public roundOrdering(roundId: number, method: SeedOrdering): void {
const round = this.storage.select("round", roundId);
if (!round) throw Error("This round does not exist.");
const stage = this.storage.select("stage", round.stage_id);
if (!stage) throw Error("Stage not found.");
helpers.ensureNotRoundRobin(stage);
this.updateRoundOrdering(round, method);
}
/**
* Updates child count of all matches of a given level.
*
* @param level The level at which to act.
* @param id ID of the chosen level.
* @param childCount The target child count.
*/
public matchChildCount(
level: ChildCountLevel,
id: number,
childCount: number
): void {
switch (level) {
case "stage":
this.updateStageMatchChildCount(id, childCount);
break;
case "group":
this.updateGroupMatchChildCount(id, childCount);
break;
case "round":
this.updateRoundMatchChildCount(id, childCount);
break;
case "match":
// eslint-disable-next-line no-case-declarations
const match = this.storage.select("match", id);
if (!match) throw Error("Match not found.");
this.adjustMatchChildGames(match, childCount);
break;
default:
throw Error("Unknown child count level.");
}
}
/**
* Updates the seeding of a stage.
*
* @param stageId ID of the stage.
* @param seeding The new seeding.
*/
public seeding(stageId: number, seeding: Seeding): void {
this.updateSeeding(stageId, seeding);
}
/**
* Confirms the seeding of a stage.
*
* This will convert TBDs to BYEs and propagate them.
*
* @param stageId ID of the stage.
*/
public confirmSeeding(stageId: number): void {
this.confirmCurrentSeeding(stageId);
}
/**
* Update the seed ordering of a round.
*
* @param round The round of which to update the ordering.
* @param method The new ordering method.
*/
private updateRoundOrdering(round: Round, method: SeedOrdering): void {
const matches = this.storage.select("match", { round_id: round.id });
if (!matches) throw Error("This round has no match.");
if (matches.some((match) => match.status > Status.Ready))
throw Error("At least one match has started or is completed.");
const stage = this.storage.select("stage", round.stage_id);
if (!stage) throw Error("Stage not found.");
if (stage.settings.size === undefined) throw Error("Undefined stage size.");
const group = this.storage.select("group", round.group_id);
if (!group) throw Error("Group not found.");
const inLoserBracket = helpers.isLoserBracket(stage.type, group.number);
const roundCountLB = helpers.getLowerBracketRoundCount(stage.settings.size);
const seeds = helpers.getSeeds(
inLoserBracket,
round.number,
roundCountLB,
matches.length
);
const positions = ordering[method](seeds);
this.applyRoundOrdering(round.number, matches, positions);
}
/**
* Updates child count of all matches of a stage.
*
* @param stageId ID of the stage.
* @param childCount The target child count.
*/
private updateStageMatchChildCount(
stageId: number,
childCount: number
): void {
if (
!this.storage.update(
"match",
{ stage_id: stageId },
{ child_count: childCount }
)
)
throw Error("Could not update the match.");
const matches = this.storage.select("match", { stage_id: stageId });
if (!matches) throw Error("This stage has no match.");
for (const match of matches) this.adjustMatchChildGames(match, childCount);
}
/**
* Updates child count of all matches of a group.
*
* @param groupId ID of the group.
* @param childCount The target child count.
*/
private updateGroupMatchChildCount(
groupId: number,
childCount: number
): void {
if (
!this.storage.update(
"match",
{ group_id: groupId },
{ child_count: childCount }
)
)
throw Error("Could not update the match.");
const matches = this.storage.select("match", { group_id: groupId });
if (!matches) throw Error("This group has no match.");
for (const match of matches) this.adjustMatchChildGames(match, childCount);
}
/**
* Updates child count of all matches of a round.
*
* @param roundId ID of the round.
* @param childCount The target child count.
*/
private updateRoundMatchChildCount(
roundId: number,
childCount: number
): void {
if (
!this.storage.update(
"match",
{ round_id: roundId },
{ child_count: childCount }
)
)
throw Error("Could not update the match.");
const matches = this.storage.select("match", { round_id: roundId });
if (!matches) throw Error("This round has no match.");
for (const match of matches) this.adjustMatchChildGames(match, childCount);
}
/**
* Updates the ordering of participants in a round's matches.
*
* @param roundNumber The number of the round.
* @param matches The matches of the round.
* @param positions The new positions.
*/
private applyRoundOrdering(
roundNumber: number,
matches: Match[],
positions: number[]
): void {
for (const match of matches) {
const updated = { ...match };
updated.opponent1 = helpers.findPosition(matches, positions.shift()!);
// The only rounds where we have a second ordered participant are first rounds of brackets (upper and lower).
if (roundNumber === 1)
updated.opponent2 = helpers.findPosition(matches, positions.shift()!);
if (!this.storage.update("match", updated.id, updated))
throw Error("Could not update the match.");
}
}
/**
* Adds or deletes match games of a match based on a target child count.
*
* @param match The match of which child games need to be adjusted.
* @param targetChildCount The target child count.
*/
private adjustMatchChildGames(match: Match, targetChildCount: number): void {
const games = this.storage.select("match_game", {
parent_id: match.id,
});
let childCount = games ? games.length : 0;
while (childCount < targetChildCount) {
const id = this.storage.insert("match_game", {
number: childCount + 1,
stage_id: match.stage_id,
parent_id: match.id,
status: match.status,
opponent1: { id: null },
opponent2: { id: null },
});
if (id === -1)
throw Error("Could not adjust the match games when inserting.");
childCount++;
}
while (childCount > targetChildCount) {
const deleted = this.storage.delete("match_game", {
parent_id: match.id,
number: childCount,
});
if (!deleted)
throw Error("Could not adjust the match games when deleting.");
childCount--;
}
if (
!this.storage.update("match", match.id, {
...match,
child_count: targetChildCount,
})
)
throw Error("Could not update the match.");
}
}

View File

@ -0,0 +1 @@
Taken from https://github.com/Drarig29/brackets-storage

View File

@ -0,0 +1,250 @@
import clone from "just-clone";
import type {
CrudInterface,
OmitId,
Table,
Database,
} from "~/modules/brackets-manager";
export class InMemoryDatabase implements CrudInterface {
protected data: Database = {
participant: [],
stage: [],
group: [],
round: [],
match: [],
match_game: [],
};
/**
* @param data "import" data from external
*/
setData(data: Database): void {
this.data = data;
}
/**
* @param partial Filter
*/
makeFilter(partial: any): (entry: any) => boolean {
return (entry: any): boolean => {
let result = true;
for (const key of Object.keys(partial))
result = result && entry[key] === partial[key];
return result;
};
}
/**
* Clearing all of the data
*/
reset(): void {
this.data = {
participant: [],
stage: [],
group: [],
round: [],
match: [],
match_game: [],
};
}
insert<T>(table: Table, value: OmitId<T>): number;
/**
* Inserts multiple values in the database.
*
* @param table Where to insert.
* @param values What to insert.
*/
insert<T>(table: Table, values: OmitId<T>[]): boolean;
/**
* Implementation of insert
*
* @param table Where to insert.
* @param values What to insert.
*/
insert<T>(table: Table, values: OmitId<T> | OmitId<T>[]): number | boolean {
let id =
this.data[table].length > 0
? Math.max(...this.data[table].map((d) => d.id)) + 1
: 0;
if (!Array.isArray(values)) {
try {
// @ts-expect-error imported
this.data[table].push({ id, ...values });
} catch (error) {
return -1;
}
return id;
}
try {
values.forEach((object) => {
// @ts-expect-error imported
this.data[table].push({ id: id++, ...object });
});
} catch (error) {
return false;
}
return true;
}
/**
* Gets all data from a table in the database.
*
* @param table Where to get from.
*/
select<T>(table: Table): T[] | null;
/**
* Gets specific data from a table in the database.
*
* @param table Where to get from.
* @param id What to get.
*/
select<T>(table: Table, id: number): T | null;
/**
* Gets data from a table in the database with a filter.
*
* @param table Where to get from.
* @param filter An object to filter data.
*/
select<T>(table: Table, filter: Partial<T>): T[] | null;
/**
* @param table Where to get from.
* @param arg Arg.
*/
select<T>(table: Table, arg?: number | Partial<T>): T[] | null {
try {
if (arg === undefined) {
// @ts-expect-error imported
return this.data[table].map(clone);
}
if (typeof arg === "number") {
// @ts-expect-error imported
return clone(this.data[table].find((d) => d.id === arg));
}
// @ts-expect-error imported
return this.data[table].filter(this.makeFilter(arg)).map(clone);
} catch (error) {
return null;
}
}
/**
* Updates data in a table.
*
* @param table Where to update.
* @param id What to update.
* @param value How to update.
*/
update<T>(table: Table, id: number, value: T): boolean;
/**
* Updates data in a table.
*
* @param table Where to update.
* @param filter An object to filter data.
* @param value How to update.
*/
update<T>(table: Table, filter: Partial<T>, value: Partial<T>): boolean;
/**
* Updates data in a table.
*
* @param table Where to update.
* @param arg
* @param value How to update.
*/
update<T>(
table: Table,
arg: number | Partial<T>,
value?: Partial<T>
): boolean {
if (typeof arg === "number") {
try {
// @ts-expect-error imported
this.data[table][arg] = value;
return true;
} catch (error) {
return false;
}
}
// @ts-expect-error imported
const values = this.data[table].filter(this.makeFilter(arg));
if (!values) {
return false;
}
values.forEach((v: { id: any }) => {
const existing = this.data[table][v.id];
for (const key in value) {
if (
// @ts-expect-error imported
existing[key] &&
// @ts-expect-error imported
typeof existing[key] === "object" &&
typeof value[key] === "object"
) {
// @ts-expect-error imported
Object.assign(existing[key], value[key]); // For opponent objects, this does a deep merge of level 2.
} else {
// @ts-expect-error imported
existing[key] = value[key]; // Otherwise, do a simple value assignment.
}
}
this.data[table][v.id] = existing;
});
return true;
}
/**
* Empties a table completely.
*
* @param table Where to delete everything.
*/
delete(table: Table): boolean;
/**
* Delete data in a table, based on a filter.
*
* @param table Where to delete in.
* @param filter An object to filter data.
*/
delete<T>(table: Table, filter: Partial<T>): boolean;
/**
* Delete data in a table, based on a filter.
*
* @param table Where to delete in.
* @param filter An object to filter data.
*/
delete<T>(table: Table, filter?: Partial<T>): boolean {
const values = this.data[table];
if (!values) {
return false;
}
if (!filter) {
this.data[table] = [];
return true;
}
const predicate = this.makeFilter(filter);
const negativeFilter = (value: any): boolean => !predicate(value);
// @ts-expect-error imported
this.data[table] = values.filter(negativeFilter);
return true;
}
}

View File

@ -10,3 +10,5 @@ export const DEFAULT_MAP_POOL = new MapPool([
{ mode: "CB", stageId: 8 },
{ mode: "CB", stageId: 3 },
]);
export const sourceTypes = ["DEFAULT", "TIEBREAKER", "BOTH"] as const;

Some files were not shown because too many files have changed in this diff Show More