mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
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:
parent
ab5c6cf7bb
commit
ef78d3a2c2
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
3
app/components/Divider.tsx
Normal file
3
app/components/Divider.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function Divider({ children }: { children: React.ReactNode }) {
|
||||
return <div className="divider">{children}</div>;
|
||||
}
|
||||
35
app/components/Draggable.tsx
Normal file
35
app/components/Draggable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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`}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 *
|
||||
|
|
|
|||
4
app/db/models/calendar/createTournament.sql
Normal file
4
app/db/models/calendar/createTournament.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
insert into
|
||||
"Tournament" ("mapPickingStyle", "format")
|
||||
values
|
||||
(@mapPickingStyle, @format) returning *
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 *;
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
});
|
||||
|
|
|
|||
136
app/db/types.ts
136
app/db/types.ts
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
48
app/features/tournament-bracket/brackets-viewer.css
Normal file
48
app/features/tournament-bracket/brackets-viewer.css
Normal 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;
|
||||
}
|
||||
336
app/features/tournament-bracket/components/ScoreReporter.tsx
Normal file
336
app/features/tournament-bracket/components/ScoreReporter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
200
app/features/tournament-bracket/components/TeamRosterInputs.tsx
Normal file
200
app/features/tournament-bracket/components/TeamRosterInputs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
app/features/tournament-bracket/core/bestOf.server.ts
Normal file
55
app/features/tournament-bracket/core/bestOf.server.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { getTournamentManager } from "./manager";
|
||||
|
|
@ -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;
|
||||
}
|
||||
10
app/features/tournament-bracket/core/emitters.server.ts
Normal file
10
app/features/tournament-bracket/core/emitters.server.ts
Normal 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;
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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"],
|
||||
};
|
||||
};
|
||||
|
|
@ -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),
|
||||
}));
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
11
app/features/tournament-bracket/queries/setBestOf.server.ts
Normal file
11
app/features/tournament-bracket/queries/setBestOf.server.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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);
|
||||
};
|
||||
});
|
||||
};
|
||||
260
app/features/tournament-bracket/routes/to.$id.brackets.tsx
Normal file
260
app/features/tournament-bracket/routes/to.$id.brackets.tsx
Normal 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]);
|
||||
}
|
||||
|
|
@ -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);
|
||||
};
|
||||
});
|
||||
};
|
||||
481
app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx
Normal file
481
app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"),
|
||||
}),
|
||||
]);
|
||||
|
|
@ -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();
|
||||
143
app/features/tournament-bracket/tournament-bracket-utils.ts
Normal file
143
app/features/tournament-bracket/tournament-bracket-utils.ts
Normal 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)];
|
||||
}
|
||||
242
app/features/tournament-bracket/tournament-bracket.css
Normal file
242
app/features/tournament-bracket/tournament-bracket.css
Normal 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;
|
||||
}
|
||||
}
|
||||
11
app/features/tournament/index.ts
Normal file
11
app/features/tournament/index.ts
Normal 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";
|
||||
30
app/features/tournament/queries/changeTeamOwner.server.ts
Normal file
30
app/features/tournament/queries/changeTeamOwner.server.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
);
|
||||
12
app/features/tournament/queries/checkIn.server.ts
Normal file
12
app/features/tournament/queries/checkIn.server.ts
Normal 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 });
|
||||
}
|
||||
10
app/features/tournament/queries/checkOut.server.ts
Normal file
10
app/features/tournament/queries/checkOut.server.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
10
app/features/tournament/queries/deleteTeam.server.ts
Normal file
10
app/features/tournament/queries/deleteTeam.server.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
37
app/features/tournament/queries/findTrustedPlayers.server.ts
Normal file
37
app/features/tournament/queries/findTrustedPlayers.server.ts
Normal 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 });
|
||||
}
|
||||
21
app/features/tournament/queries/giveTrust.server.ts
Normal file
21
app/features/tournament/queries/giveTrust.server.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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 }));
|
||||
}
|
||||
|
|
@ -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 });
|
||||
};
|
||||
21
app/features/tournament/queries/maxXPowers.server.ts
Normal file
21
app/features/tournament/queries/maxXPowers.server.ts
Normal 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>);
|
||||
};
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
26
app/features/tournament/queries/updateTeamSeeds.server.ts
Normal file
26
app/features/tournament/queries/updateTeamSeeds.server.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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't then you will be
|
||||
playing on your opponent'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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
316
app/features/tournament/routes/to.$id.seeds.tsx
Normal file
316
app/features/tournament/routes/to.$id.seeds.tsx
Normal 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;
|
||||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
16
app/hooks/useAutoRerender.ts
Normal file
16
app/hooks/useAutoRerender.ts
Normal 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);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
33
app/hooks/useTimeoutState.ts
Normal file
33
app/hooks/useTimeoutState.ts
Normal 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];
|
||||
};
|
||||
1
app/modules/brackets-manager/README.md
Normal file
1
app/modules/brackets-manager/README.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
Taken from https://github.com/Drarig29/brackets-manager.js
|
||||
667
app/modules/brackets-manager/base/getter.ts
Normal file
667
app/modules/brackets-manager/base/getter.ts
Normal 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.");
|
||||
}
|
||||
}
|
||||
436
app/modules/brackets-manager/base/updater.ts
Normal file
436
app/modules/brackets-manager/base/updater.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
1033
app/modules/brackets-manager/create.ts
Normal file
1033
app/modules/brackets-manager/create.ts
Normal file
File diff suppressed because it is too large
Load Diff
59
app/modules/brackets-manager/delete.ts
Normal file
59
app/modules/brackets-manager/delete.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
164
app/modules/brackets-manager/find.ts
Normal file
164
app/modules/brackets-manager/find.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
473
app/modules/brackets-manager/get.ts
Normal file
473
app/modules/brackets-manager/get.ts
Normal 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;
|
||||
};
|
||||
2022
app/modules/brackets-manager/helpers.ts
Normal file
2022
app/modules/brackets-manager/helpers.ts
Normal file
File diff suppressed because it is too large
Load Diff
32
app/modules/brackets-manager/index.ts
Normal file
32
app/modules/brackets-manager/index.ts
Normal 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,
|
||||
};
|
||||
142
app/modules/brackets-manager/manager.ts
Normal file
142
app/modules/brackets-manager/manager.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
114
app/modules/brackets-manager/ordering.ts
Normal file
114
app/modules/brackets-manager/ordering.ts
Normal 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",
|
||||
],
|
||||
};
|
||||
98
app/modules/brackets-manager/reset.ts
Normal file
98
app/modules/brackets-manager/reset.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
230
app/modules/brackets-manager/types.ts
Normal file
230
app/modules/brackets-manager/types.ts
Normal 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;
|
||||
}
|
||||
319
app/modules/brackets-manager/update.ts
Normal file
319
app/modules/brackets-manager/update.ts
Normal 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.");
|
||||
}
|
||||
}
|
||||
1
app/modules/brackets-memory-db/README.md
Normal file
1
app/modules/brackets-memory-db/README.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
Taken from https://github.com/Drarig29/brackets-storage
|
||||
250
app/modules/brackets-memory-db/index.ts
Normal file
250
app/modules/brackets-memory-db/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue
Block a user