From ef78d3a2c28e019ce616caf20bc1423bcc244dda Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 15 May 2023 22:37:43 +0300 Subject: [PATCH] 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 --- app/components/Catcher.tsx | 10 +- app/components/Divider.tsx | 3 + app/components/Draggable.tsx | 35 + app/components/Image.tsx | 4 +- app/components/icons/Checkmark.tsx | 9 +- app/db/models/calendar/create.sql | 6 +- app/db/models/calendar/createTournament.sql | 4 + app/db/models/calendar/eventsToReport.sql | 3 +- .../calendar/findAllBetweenTwoTimestamps.sql | 3 +- app/db/models/calendar/findById.sql | 5 +- app/db/models/calendar/queries.server.ts | 75 +- app/db/models/calendar/update.sql | 6 +- app/db/seed/index.ts | 105 +- app/db/types.ts | 136 +- .../build-analyzer/routes/analyzer.tsx | 4 +- .../img-upload/routes/upload.admin.tsx | 2 +- app/features/img-upload/routes/upload.tsx | 14 +- .../team/routes/t.$customUrl.edit.tsx | 2 +- .../team/routes/t.$customUrl.join.tsx | 3 +- .../team/routes/t.$customUrl.roster.tsx | 4 +- app/features/team/routes/t.$customUrl.tsx | 5 +- app/features/team/routes/t.tsx | 6 +- .../tournament-bracket/brackets-viewer.css | 48 + .../components/ScoreReporter.tsx | 336 +++ .../components/ScoreReporterRosters.tsx | 153 ++ .../components/TeamRosterInputs.tsx | 200 ++ .../tournament-bracket/core/bestOf.server.ts | 55 + .../core/brackets-manager/crud-db.server.ts | 475 ++++ .../core/brackets-manager/crud.server.ts | 347 +++ .../core/brackets-manager/index.ts | 1 + .../core/brackets-manager/manager.ts | 13 + .../core/emitters.server.ts | 10 + ...eteTournamentMatchGameResultById.server.ts | 10 + .../findAllMatchesByTournamentId.server.ts | 25 + .../queries/findMatchById.server.ts | 29 + .../queries/findResultsByMatchId.server.ts | 43 + .../insertTournamentMatchGameResult.server.ts | 16 + ...namentMatchGameResultParticipant.server.ts | 15 + .../queries/setBestOf.server.ts | 11 + .../routes/to.$id.brackets.subscribe.tsx | 30 + .../routes/to.$id.brackets.tsx | 260 +++ .../routes/to.$id.matches.$mid.subscribe.tsx | 28 + .../routes/to.$id.matches.$mid.tsx | 481 ++++ .../tournament-bracket-schemas.server.ts | 36 + .../tournament-bracket-utils.test.ts | 68 + .../tournament-bracket-utils.ts | 143 ++ .../tournament-bracket/tournament-bracket.css | 242 ++ app/features/tournament/index.ts | 11 + .../queries/changeTeamOwner.server.ts | 30 + .../tournament/queries/checkIn.server.ts | 12 + .../tournament/queries/checkOut.server.ts | 10 + .../tournament/queries/createTeam.server.ts | 19 +- .../tournament/queries/deleteTeam.server.ts | 10 + .../queries/findByIdentifier.server.ts | 59 +- .../tournament/queries/findOwnTeam.server.ts | 22 +- .../queries/findTeamByInviteCode.server.ts | 4 +- ...r.ts => findTeamsByTournamentId.server.ts} | 27 +- .../queries/findTrustedPlayers.server.ts | 37 + .../tournament/queries/giveTrust.server.ts | 21 + .../queries/hasTournamentStarted.server.ts | 12 + ...Team.server.ts => joinLeaveTeam.server.ts} | 24 + .../tournament/queries/maxXPowers.server.ts | 21 + .../queries/updateIsBeforeStart.server.ts | 20 - .../updateShowMapListGenerator.server.ts | 20 + .../queries/updateTeamInfo.server.ts | 6 +- .../queries/updateTeamSeeds.server.ts | 26 + .../tournament/routes/to.$id.admin.tsx | 411 +++- .../tournament/routes/to.$id.index.tsx | 18 +- .../tournament/routes/to.$id.join.tsx | 105 +- .../tournament/routes/to.$id.maps.tsx | 21 +- .../tournament/routes/to.$id.register.tsx | 498 +++- .../tournament/routes/to.$id.seeds.tsx | 316 +++ .../tournament/routes/to.$id.teams.tsx | 51 +- app/features/tournament/routes/to.$id.tsx | 106 +- .../tournament/tournament-constants.ts | 2 +- app/features/tournament/tournament-hooks.ts | 4 +- .../tournament/tournament-schemas.server.ts | 52 +- app/features/tournament/tournament-utils.ts | 88 +- app/features/tournament/tournament.css | 101 +- ...teListEntry.tsx => useAnimateListEntry.ts} | 0 app/hooks/useAutoRerender.ts | 16 + ...hParamState.tsx => useSearchParamState.ts} | 0 app/hooks/useTimeoutState.ts | 33 + app/modules/brackets-manager/README.md | 1 + app/modules/brackets-manager/base/getter.ts | 667 ++++++ app/modules/brackets-manager/base/updater.ts | 436 ++++ app/modules/brackets-manager/create.ts | 1033 +++++++++ app/modules/brackets-manager/delete.ts | 59 + app/modules/brackets-manager/find.ts | 164 ++ app/modules/brackets-manager/get.ts | 473 ++++ app/modules/brackets-manager/helpers.ts | 2022 +++++++++++++++++ app/modules/brackets-manager/index.ts | 32 + app/modules/brackets-manager/manager.ts | 142 ++ app/modules/brackets-manager/ordering.ts | 114 + app/modules/brackets-manager/reset.ts | 98 + app/modules/brackets-manager/types.ts | 230 ++ app/modules/brackets-manager/update.ts | 319 +++ app/modules/brackets-memory-db/README.md | 1 + app/modules/brackets-memory-db/index.ts | 250 ++ .../constants.ts | 2 + .../generation.test.ts | 122 +- .../tournament-map-list-generator/index.ts | 2 + .../tournament-map-list.ts | 8 +- .../tournament-map-list-generator/types.ts | 10 +- app/permissions.ts | 30 +- app/root.tsx | 5 + app/routes/badges/$id.tsx | 2 +- app/routes/calendar/$id/index.tsx | 8 +- app/routes/calendar/$id/report-winners.tsx | 4 +- app/routes/calendar/index.tsx | 31 +- app/routes/calendar/new.tsx | 45 +- app/routes/calendar/tags.json | 3 + app/routes/patrons.tsx | 2 +- app/routes/seed.tsx | 19 +- app/styles/common.css | 24 + app/styles/layout.css | 2 - app/styles/utils.css | 16 + app/styles/vars.css | 2 + app/utils/playwright.ts | 39 +- app/utils/remix.ts | 17 +- app/utils/sql.ts | 4 + app/utils/strings.ts | 4 + app/utils/urls.ts | 26 +- e2e/tournament.spec.ts | 194 ++ migrations/014-full-tournament.js | 1 - migrations/026-full-tournament-v2.js | 234 ++ package-lock.json | 200 +- package.json | 7 +- public/locales/da/tournament.json | 5 +- public/locales/de/tournament.json | 5 +- public/locales/en/calendar.json | 4 +- public/locales/en/common.json | 2 + public/locales/en/tournament.json | 9 +- public/locales/fr/tournament.json | 5 +- public/locales/it/tournament.json | 5 +- public/locales/ja/tournament.json | 5 +- public/locales/pl/tournament.json | 5 +- public/locales/ru/tournament.json | 5 +- public/locales/zh/tournament.json | 5 +- remix.config.js | 18 + scripts/create-analyzer-json.ts | 6 - tsconfig.json | 2 +- 142 files changed, 12284 insertions(+), 633 deletions(-) create mode 100644 app/components/Divider.tsx create mode 100644 app/components/Draggable.tsx create mode 100644 app/db/models/calendar/createTournament.sql create mode 100644 app/features/tournament-bracket/brackets-viewer.css create mode 100644 app/features/tournament-bracket/components/ScoreReporter.tsx create mode 100644 app/features/tournament-bracket/components/ScoreReporterRosters.tsx create mode 100644 app/features/tournament-bracket/components/TeamRosterInputs.tsx create mode 100644 app/features/tournament-bracket/core/bestOf.server.ts create mode 100644 app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts create mode 100644 app/features/tournament-bracket/core/brackets-manager/crud.server.ts create mode 100644 app/features/tournament-bracket/core/brackets-manager/index.ts create mode 100644 app/features/tournament-bracket/core/brackets-manager/manager.ts create mode 100644 app/features/tournament-bracket/core/emitters.server.ts create mode 100644 app/features/tournament-bracket/queries/deleteTournamentMatchGameResultById.server.ts create mode 100644 app/features/tournament-bracket/queries/findAllMatchesByTournamentId.server.ts create mode 100644 app/features/tournament-bracket/queries/findMatchById.server.ts create mode 100644 app/features/tournament-bracket/queries/findResultsByMatchId.server.ts create mode 100644 app/features/tournament-bracket/queries/insertTournamentMatchGameResult.server.ts create mode 100644 app/features/tournament-bracket/queries/insertTournamentMatchGameResultParticipant.server.ts create mode 100644 app/features/tournament-bracket/queries/setBestOf.server.ts create mode 100644 app/features/tournament-bracket/routes/to.$id.brackets.subscribe.tsx create mode 100644 app/features/tournament-bracket/routes/to.$id.brackets.tsx create mode 100644 app/features/tournament-bracket/routes/to.$id.matches.$mid.subscribe.tsx create mode 100644 app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx create mode 100644 app/features/tournament-bracket/tournament-bracket-schemas.server.ts create mode 100644 app/features/tournament-bracket/tournament-bracket-utils.test.ts create mode 100644 app/features/tournament-bracket/tournament-bracket-utils.ts create mode 100644 app/features/tournament-bracket/tournament-bracket.css create mode 100644 app/features/tournament/index.ts create mode 100644 app/features/tournament/queries/changeTeamOwner.server.ts create mode 100644 app/features/tournament/queries/checkIn.server.ts create mode 100644 app/features/tournament/queries/checkOut.server.ts create mode 100644 app/features/tournament/queries/deleteTeam.server.ts rename app/features/tournament/queries/{findTeamsByEventId.server.ts => findTeamsByTournamentId.server.ts} (70%) create mode 100644 app/features/tournament/queries/findTrustedPlayers.server.ts create mode 100644 app/features/tournament/queries/giveTrust.server.ts create mode 100644 app/features/tournament/queries/hasTournamentStarted.server.ts rename app/features/tournament/queries/{joinTeam.server.ts => joinLeaveTeam.server.ts} (63%) create mode 100644 app/features/tournament/queries/maxXPowers.server.ts delete mode 100644 app/features/tournament/queries/updateIsBeforeStart.server.ts create mode 100644 app/features/tournament/queries/updateShowMapListGenerator.server.ts create mode 100644 app/features/tournament/queries/updateTeamSeeds.server.ts create mode 100644 app/features/tournament/routes/to.$id.seeds.tsx rename app/hooks/{useAnimateListEntry.tsx => useAnimateListEntry.ts} (100%) create mode 100644 app/hooks/useAutoRerender.ts rename app/hooks/{useSearchParamState.tsx => useSearchParamState.ts} (100%) create mode 100644 app/hooks/useTimeoutState.ts create mode 100644 app/modules/brackets-manager/README.md create mode 100644 app/modules/brackets-manager/base/getter.ts create mode 100644 app/modules/brackets-manager/base/updater.ts create mode 100644 app/modules/brackets-manager/create.ts create mode 100644 app/modules/brackets-manager/delete.ts create mode 100644 app/modules/brackets-manager/find.ts create mode 100644 app/modules/brackets-manager/get.ts create mode 100644 app/modules/brackets-manager/helpers.ts create mode 100644 app/modules/brackets-manager/index.ts create mode 100644 app/modules/brackets-manager/manager.ts create mode 100644 app/modules/brackets-manager/ordering.ts create mode 100644 app/modules/brackets-manager/reset.ts create mode 100644 app/modules/brackets-manager/types.ts create mode 100644 app/modules/brackets-manager/update.ts create mode 100644 app/modules/brackets-memory-db/README.md create mode 100644 app/modules/brackets-memory-db/index.ts create mode 100644 e2e/tournament.spec.ts create mode 100644 migrations/026-full-tournament-v2.js diff --git a/app/components/Catcher.tsx b/app/components/Catcher.tsx index 1f049dffb..ef49848aa 100644 --- a/app/components/Catcher.tsx +++ b/app/components/Catcher.tsx @@ -62,10 +62,14 @@ export function Catcher() { return (

Error {caught.status}

- {caught.data ? ( - {JSON.stringify(caught.data, null, 2)} - ) : null} +
+ Please include the message below if any and an explanation on what + you were doing: +
+ {caught.data ? ( +
{JSON.stringify(JSON.parse(caught.data), null, 2)}
+ ) : null}
); } diff --git a/app/components/Divider.tsx b/app/components/Divider.tsx new file mode 100644 index 000000000..32b6ea7d5 --- /dev/null +++ b/app/components/Divider.tsx @@ -0,0 +1,3 @@ +export function Divider({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/app/components/Draggable.tsx b/app/components/Draggable.tsx new file mode 100644 index 000000000..1c16c5563 --- /dev/null +++ b/app/components/Draggable.tsx @@ -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 ( +
  • + {children} +
  • + ); +} diff --git a/app/components/Image.tsx b/app/components/Image.tsx index 3378d65ba..7fa39e810 100644 --- a/app/components/Image.tsx +++ b/app/components/Image.tsx @@ -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 ( - + @lowerLimitTime and "startTime" < @upperLimitTime - and "CalendarEvent"."participantCount" is null \ No newline at end of file + and "CalendarEvent"."participantCount" is null + and "CalendarEvent"."tournamentId" is null diff --git a/app/db/models/calendar/findAllBetweenTwoTimestamps.sql b/app/db/models/calendar/findAllBetweenTwoTimestamps.sql index 893f2568f..3eae540af 100644 --- a/app/db/models/calendar/findAllBetweenTwoTimestamps.sql +++ b/app/db/models/calendar/findAllBetweenTwoTimestamps.sql @@ -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 \ No newline at end of file + "CalendarEventDate"."startTime" asc diff --git a/app/db/models/calendar/findById.sql b/app/db/models/calendar/findById.sql index 66eaf61dc..4ee704d73 100644 --- a/app/db/models/calendar/findById.sql +++ b/app/db/models/calendar/findById.sql @@ -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 diff --git a/app/db/models/calendar/queries.server.ts b/app/db/models/calendar/queries.server.ts index cf8d69455..ddf101b4f 100644 --- a/app/db/models/calendar/queries.server.ts +++ b/app/db/models/calendar/queries.server.ts @@ -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 +) => { + 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; badges: Array; mapPoolMaps?: Array>; + 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 & { +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; - 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; 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 & + Pick< + CalendarEvent, + "name" | "discordUrl" | "bracketUrl" | "tags" | "tournamentId" + > & Pick & { eventDateId: CalendarEventDate["id"]; } & Pick & { @@ -354,9 +391,9 @@ export function findById(id: CalendarEvent["id"]) { | "tags" | "authorId" | "participantCount" - | "toToolsEnabled" - | "toToolsMode" + | "tournamentId" > & + Pick & Pick & Pick< User, diff --git a/app/db/models/calendar/update.sql b/app/db/models/calendar/update.sql index f183c8a48..fec802434 100644 --- a/app/db/models/calendar/update.sql +++ b/app/db/models/calendar/update.sql @@ -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 *; diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 68dda1d30..508531d05 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -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()), }); diff --git a/app/db/types.ts b/app/db/types.ts index 90a491a94..1892022ec 100644 --- a/app/db/types.ts +++ b/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; diff --git a/app/features/build-analyzer/routes/analyzer.tsx b/app/features/build-analyzer/routes/analyzer.tsx index fb122e800..fca8f8812 100644 --- a/app/features/build-analyzer/routes/analyzer.tsx +++ b/app/features/build-analyzer/routes/analyzer.tsx @@ -1393,10 +1393,10 @@ function ConsumptionTable({ const opt2 = options2ForThisSubsUsed[i]; const contents = !isComparing - ? opt1!.value + ? opt1.value : `${opt1?.value ?? "-"}/${opt2?.value ?? "-"}`; - cells.push({contents}); + cells.push({contents}); } return ( diff --git a/app/features/img-upload/routes/upload.admin.tsx b/app/features/img-upload/routes/upload.admin.tsx index 24ed5b63b..56e5a6868 100644 --- a/app/features/img-upload/routes/upload.admin.tsx +++ b/app/features/img-upload/routes/upload.admin.tsx @@ -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); diff --git a/app/features/img-upload/routes/upload.tsx b/app/features/img-upload/routes/upload.tsx index 8240e5c95..c8b28067a 100644 --- a/app/features/img-upload/routes/upload.tsx +++ b/app/features/img-upload/routes/upload.tsx @@ -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, diff --git a/app/features/team/routes/t.$customUrl.edit.tsx b/app/features/team/routes/t.$customUrl.edit.tsx index 87774544d..b44d49449 100644 --- a/app/features/team/routes/t.$customUrl.edit.tsx +++ b/app/features/team/routes/t.$customUrl.edit.tsx @@ -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, diff --git a/app/features/team/routes/t.$customUrl.join.tsx b/app/features/team/routes/t.$customUrl.join.tsx index 84b492f5f..63b10e42a 100644 --- a/app/features/team/routes/t.$customUrl.join.tsx +++ b/app/features/team/routes/t.$customUrl.join.tsx @@ -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 }); diff --git a/app/features/team/routes/t.$customUrl.roster.tsx b/app/features/team/routes/t.$customUrl.roster.tsx index 1775da97c..6088df92b 100644 --- a/app/features/team/routes/t.$customUrl.roster.tsx +++ b/app/features/team/routes/t.$customUrl.roster.tsx @@ -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; } diff --git a/app/features/team/routes/t.$customUrl.tsx b/app/features/team/routes/t.$customUrl.tsx index 1ea900e7e..f9dfd740f 100644 --- a/app/features/team/routes/t.$customUrl.tsx +++ b/app/features/team/routes/t.$customUrl.tsx @@ -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 }); diff --git a/app/features/team/routes/t.tsx b/app/features/team/routes/t.tsx index a90baf061..8f6ad7406 100644 --- a/app/features/team/routes/t.tsx +++ b/app/features/team/routes/t.tsx @@ -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 diff --git a/app/features/tournament-bracket/brackets-viewer.css b/app/features/tournament-bracket/brackets-viewer.css new file mode 100644 index 000000000..dc62352e5 --- /dev/null +++ b/app/features/tournament-bracket/brackets-viewer.css @@ -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; +} diff --git a/app/features/tournament-bracket/components/ScoreReporter.tsx b/app/features/tournament-bracket/components/ScoreReporter.tsx new file mode 100644 index 000000000..763e134ca --- /dev/null +++ b/app/features/tournament-bracket/components/ScoreReporter.tsx @@ -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["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(); + const data = useLoaderData(); + + 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 ? ( + <> + {resolveHostingTeam(teams).name} hosts + + ) : null, + showFullInfos ? ( + <> + Pass {resolveRoomPass(data.match.id)} + + ) : null, + showFullInfos ? ( + <> + Pool {HACKY_resolvePoolCode(parentRouteData.event)} + + ) : null, + <> + + {scoreOne}-{scoreTwo} + {" "} + (Best of {data.match.bestOf}) + , + ]; + + const matchIsLockedError = actionData?.error === "locked"; + + return ( +
    + + {currentPosition > 0 && !presentational && type === "EDIT" && ( +
    + +
    + + Undo last score + +
    +
    + )} + {canAdminTournament({ user, event: parentRouteData.event }) && + presentational && + !matchIsLockedError && ( +
    +
    + + Reopen match + +
    +
    + )} + {matchIsLockedError && ( +
    + + Match is locked + +
    + )} +
    + + {type === "EDIT" || presentational ? ( + + + + ) : null} + {result ? ( +
    + {isMounted + ? databaseTimestampToDate(result.createdAt).toLocaleString() + : "t"} +
    + ) : null} +
    + ); +} + +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 ( + <> +
    +
    +

    + + {t(`game-misc:MODE_LONG_${stage.mode}`)} on{" "} + {t(`game-misc:STAGE_${stage.stageId}`)} +

    +

    {pickInfoText()}

    +
    + {children} +
    + {infos && ( +
    + {infos.filter(Boolean).map((info, i) => ( +
    {info}
    + ))} +
    + )} + + ); +} + +function ModeProgressIndicator({ + modes, + scores, + bestOf, + selectedResultIndex, + setSelectedResultIndex, +}: { + modes: ModeShort[]; + scores: [number, number]; + bestOf: number; + selectedResultIndex?: number; + setSelectedResultIndex?: (index: number) => void; +}) { + const data = useLoaderData(); + const { t } = useTranslation(["game-misc"]); + + const maxIndexThatWillBePlayedForSure = + mapCountPlayedInSetWithCertainty({ bestOf, scores }) - 1; + + // TODO: this should be button when we click on it + return ( +
    + {modes.map((mode, i) => { + return ( + {t(`game-misc:MODE_LONG_${mode}`)} setSelectedResultIndex?.(i)} + /> + ); + })} +
    + ); +} + +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 ( +
    +
    + {children} +
    +
    + ); +} diff --git a/app/features/tournament-bracket/components/ScoreReporterRosters.tsx b/app/features/tournament-bracket/components/ScoreReporterRosters.tsx new file mode 100644 index 000000000..d2c2caf27 --- /dev/null +++ b/app/features/tournament-bracket/components/ScoreReporterRosters.tsx @@ -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(); + + const presentational = Boolean(result); + + return ( +
    +
    + + {!presentational ? ( +
    + + + + + + + +
    + ) : null} +
    +
    + ); + + 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, + Unpacked +]): [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 ( +

    + Please choose exactly {TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL}+ + {TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL} players to report score +

    + ); + } + + if (!winnerName) { + return ( +

    + Please select the winner of this map +

    + ); + } + + return ( +
    +
    + Report {winnerName} win on{" "} + + {t(`game-misc:MODE_LONG_${currentStageWithMode.mode}`)}{" "} + {t(`game-misc:STAGE_${currentStageWithMode.stageId}`)} + + ? +
    + + Report + +
    + ); +} diff --git a/app/features/tournament-bracket/components/TeamRosterInputs.tsx b/app/features/tournament-bracket/components/TeamRosterInputs.tsx new file mode 100644 index 000000000..1f13c5af8 --- /dev/null +++ b/app/features/tournament-bracket/components/TeamRosterInputs.tsx @@ -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(); + const inputMode = ( + team: Unpacked + ): 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 ( +
    + {teams.map((team, teamI) => ( +
    +
    +
    + Team {teamI + 1} +
    +

    {team.name}

    + setWinnerId?.(team.id)} + team={teamI + 1} + /> + { + 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()); + }} + /> +
    + ))} +
    + ); +} + +/** 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 ( +
    + Winner +
    + ); + } + + return ( +
    + + +
    + ); +} + +function TeamRosterInputsCheckboxes({ + team, + checkedPlayers, + handlePlayerClick, + mode, +}: { + team: Unpacked; + 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 ( +
    + {team.members.map((member) => { + return ( +
    + handlePlayerClick(member.userId)} + />{" "} + +
    + ); + })} +
    + ); +} diff --git a/app/features/tournament-bracket/core/bestOf.server.ts b/app/features/tournament-bracket/core/bestOf.server.ts new file mode 100644 index 000000000..3fb49926e --- /dev/null +++ b/app/features/tournament-bracket/core/bestOf.server.ts @@ -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 +) { + // 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; +} diff --git a/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts b/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts new file mode 100644 index 000000000..a3c20d7c6 --- /dev/null +++ b/app/features/tournament-bracket/core/brackets-manager/crud-db.server.ts @@ -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; + } +} diff --git a/app/features/tournament-bracket/core/brackets-manager/crud.server.ts b/app/features/tournament-bracket/core/brackets-manager/crud.server.ts new file mode 100644 index 000000000..3823f381a --- /dev/null +++ b/app/features/tournament-bracket/core/brackets-manager/crud.server.ts @@ -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; + } + } +} diff --git a/app/features/tournament-bracket/core/brackets-manager/index.ts b/app/features/tournament-bracket/core/brackets-manager/index.ts new file mode 100644 index 000000000..5daec5533 --- /dev/null +++ b/app/features/tournament-bracket/core/brackets-manager/index.ts @@ -0,0 +1 @@ +export { getTournamentManager } from "./manager"; diff --git a/app/features/tournament-bracket/core/brackets-manager/manager.ts b/app/features/tournament-bracket/core/brackets-manager/manager.ts new file mode 100644 index 000000000..0c0dc84fa --- /dev/null +++ b/app/features/tournament-bracket/core/brackets-manager/manager.ts @@ -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; +} diff --git a/app/features/tournament-bracket/core/emitters.server.ts b/app/features/tournament-bracket/core/emitters.server.ts new file mode 100644 index 000000000..49bad4dcd --- /dev/null +++ b/app/features/tournament-bracket/core/emitters.server.ts @@ -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; diff --git a/app/features/tournament-bracket/queries/deleteTournamentMatchGameResultById.server.ts b/app/features/tournament-bracket/queries/deleteTournamentMatchGameResultById.server.ts new file mode 100644 index 000000000..2a67ab8cd --- /dev/null +++ b/app/features/tournament-bracket/queries/deleteTournamentMatchGameResultById.server.ts @@ -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 }); +} diff --git a/app/features/tournament-bracket/queries/findAllMatchesByTournamentId.server.ts b/app/features/tournament-bracket/queries/findAllMatchesByTournamentId.server.ts new file mode 100644 index 000000000..380f1c813 --- /dev/null +++ b/app/features/tournament-bracket/queries/findAllMatchesByTournamentId.server.ts @@ -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 { + return stm.all({ tournamentId }); +} diff --git a/app/features/tournament-bracket/queries/findMatchById.server.ts b/app/features/tournament-bracket/queries/findMatchById.server.ts new file mode 100644 index 000000000..13559b392 --- /dev/null +++ b/app/features/tournament-bracket/queries/findMatchById.server.ts @@ -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; + +export const findMatchById = (id: number) => { + const row = stm.get({ id }) as + | Pick + | undefined; + + if (!row) return; + + return { + ...row, + opponentOne: JSON.parse(row.opponentOne) as Match["opponent1"], + opponentTwo: JSON.parse(row.opponentTwo) as Match["opponent2"], + }; +}; diff --git a/app/features/tournament-bracket/queries/findResultsByMatchId.server.ts b/app/features/tournament-bracket/queries/findResultsByMatchId.server.ts new file mode 100644 index 000000000..6dbc3a629 --- /dev/null +++ b/app/features/tournament-bracket/queries/findResultsByMatchId.server.ts @@ -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; + source: TournamentMaplistSource; + createdAt: TournamentMatchGameResult["createdAt"]; +} + +export function findResultsByMatchId( + matchId: number +): Array { + const rows = stm.all({ matchId }); + + return rows.map((row) => ({ + ...row, + source: isNaN(row.source) ? row.source : Number(row.source), + participantIds: parseDBArray(row.participantIds), + })); +} diff --git a/app/features/tournament-bracket/queries/insertTournamentMatchGameResult.server.ts b/app/features/tournament-bracket/queries/insertTournamentMatchGameResult.server.ts new file mode 100644 index 000000000..a71a67c28 --- /dev/null +++ b/app/features/tournament-bracket/queries/insertTournamentMatchGameResult.server.ts @@ -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 +) { + return stm.get(args) as TournamentMatchGameResult; +} diff --git a/app/features/tournament-bracket/queries/insertTournamentMatchGameResultParticipant.server.ts b/app/features/tournament-bracket/queries/insertTournamentMatchGameResultParticipant.server.ts new file mode 100644 index 000000000..17754d952 --- /dev/null +++ b/app/features/tournament-bracket/queries/insertTournamentMatchGameResultParticipant.server.ts @@ -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); +} diff --git a/app/features/tournament-bracket/queries/setBestOf.server.ts b/app/features/tournament-bracket/queries/setBestOf.server.ts new file mode 100644 index 000000000..7cfbb3b37 --- /dev/null +++ b/app/features/tournament-bracket/queries/setBestOf.server.ts @@ -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 }); +} diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.subscribe.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.subscribe.tsx new file mode 100644 index 000000000..0704e5d6e --- /dev/null +++ b/app/features/tournament-bracket/routes/to.$id.brackets.subscribe.tsx @@ -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); + }; + }); +}; diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx new file mode 100644 index 000000000..d1adbab8a --- /dev/null +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -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(); + const ref = React.useRef(null); + const navigate = useNavigate(); + const parentRouteData = useOutletContext(); + + 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 ( +
    + + {!data.hasStarted && !lessThanTwoTeamsRegistered ? ( +
    + {!canAdminTournament({ user, event: parentRouteData.event }) ? ( + + This bracket is a preview and subject to change + + ) : ( + + When everything looks good, finalize the bracket to start the + tournament{" "} + + Finalize + + + )} +
    + ) : null} +
    + {lessThanTwoTeamsRegistered ? ( +
    + Bracket will be shown here when at least 2 teams have registered +
    + ) : null} +
    + ); +} + +// TODO: don't render this guy if tournament is over +function AutoRefresher() { + useAutoRefresh(); + + return null; +} + +function useAutoRefresh() { + const { revalidate } = useRevalidator(); + const parentRouteData = useOutletContext(); + 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]); +} diff --git a/app/features/tournament-bracket/routes/to.$id.matches.$mid.subscribe.tsx b/app/features/tournament-bracket/routes/to.$id.matches.$mid.subscribe.tsx new file mode 100644 index 000000000..5f446c75d --- /dev/null +++ b/app/features/tournament-bracket/routes/to.$id.matches.$mid.subscribe.tsx @@ -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); + }; + }); +}; diff --git a/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx b/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx new file mode 100644 index 000000000..8304395bc --- /dev/null +++ b/app/features/tournament-bracket/routes/to.$id.matches.$mid.tsx @@ -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(); + const data = useLoaderData(); + + const matchHasTwoTeams = Boolean( + data.match.opponentOne?.id && data.match.opponentTwo?.id + ); + + const matchIsOver = + data.match.opponentOne?.result === "win" || + data.match.opponentTwo?.result === "win"; + + return ( +
    + {!matchIsOver ? : null} +
    + {/* TODO: better title */} +

    Match #{data.match.id}

    + } + > + Back to bracket + +
    + {!matchHasTwoTeams ? ( +
    + Waiting for teams +
    + ) : null} + {matchIsOver ? : null} + {!matchIsOver && + typeof data.match.opponentOne?.id === "number" && + typeof data.match.opponentTwo?.id === "number" ? ( + + ) : null} +
    + ); +} + +function AutoRefresher() { + useAutoRefresh(); + + return null; +} + +function useAutoRefresh() { + const { revalidate } = useRevalidator(); + const parentRouteData = useOutletContext(); + const data = useLoaderData(); + 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(); + const parentRouteData = useOutletContext(); + + 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 ( + map.mode)} + type={ + canReportTournamentScore({ + event: parentRouteData.event, + match: data.match, + ownedTeamId: parentRouteData.ownedTeamId, + user, + }) + ? "EDIT" + : isMemberOfATeam + ? "MEMBER" + : "OTHER" + } + /> + ); +} + +function ResultsSection() { + const data = useLoaderData(); + const parentRouteData = useOutletContext(); + 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 ( + result.mode)} + selectedResultIndex={selectedResultIndex} + setSelectedResultIndex={setSelectedResultIndex} + result={result} + type="OTHER" + /> + ); +} diff --git a/app/features/tournament-bracket/tournament-bracket-schemas.server.ts b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts new file mode 100644 index 000000000..7588a7dad --- /dev/null +++ b/app/features/tournament-bracket/tournament-bracket-schemas.server.ts @@ -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"), + }), +]); diff --git a/app/features/tournament-bracket/tournament-bracket-utils.test.ts b/app/features/tournament-bracket/tournament-bracket-utils.test.ts new file mode 100644 index 000000000..241b0aa9d --- /dev/null +++ b/app/features/tournament-bracket/tournament-bracket-utils.test.ts @@ -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(); diff --git a/app/features/tournament-bracket/tournament-bracket-utils.ts b/app/features/tournament-bracket/tournament-bracket-utils.ts new file mode 100644 index 000000000..f7e964285 --- /dev/null +++ b/app/features/tournament-bracket/tournament-bracket-utils.ts @@ -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) { + 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; +}) { + 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(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)]; +} diff --git a/app/features/tournament-bracket/tournament-bracket.css b/app/features/tournament-bracket/tournament-bracket.css new file mode 100644 index 000000000..9e7f59d3e --- /dev/null +++ b/app/features/tournament-bracket/tournament-bracket.css @@ -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; + } +} diff --git a/app/features/tournament/index.ts b/app/features/tournament/index.ts new file mode 100644 index 000000000..f99f6363d --- /dev/null +++ b/app/features/tournament/index.ts @@ -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"; diff --git a/app/features/tournament/queries/changeTeamOwner.server.ts b/app/features/tournament/queries/changeTeamOwner.server.ts new file mode 100644 index 000000000..61778cf36 --- /dev/null +++ b/app/features/tournament/queries/changeTeamOwner.server.ts @@ -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, + }); + } +); diff --git a/app/features/tournament/queries/checkIn.server.ts b/app/features/tournament/queries/checkIn.server.ts new file mode 100644 index 000000000..2d214e218 --- /dev/null +++ b/app/features/tournament/queries/checkIn.server.ts @@ -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 }); +} diff --git a/app/features/tournament/queries/checkOut.server.ts b/app/features/tournament/queries/checkOut.server.ts new file mode 100644 index 000000000..6334d1444 --- /dev/null +++ b/app/features/tournament/queries/checkOut.server.ts @@ -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 }); +} diff --git a/app/features/tournament/queries/createTeam.server.ts b/app/features/tournament/queries/createTeam.server.ts index 169bdbea5..e35af862b 100644 --- a/app/features/tournament/queries/createTeam.server.ts +++ b/app/features/tournament/queries/createTeam.server.ts @@ -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 }); diff --git a/app/features/tournament/queries/deleteTeam.server.ts b/app/features/tournament/queries/deleteTeam.server.ts new file mode 100644 index 000000000..2ea674d42 --- /dev/null +++ b/app/features/tournament/queries/deleteTeam.server.ts @@ -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 }); +} diff --git a/app/features/tournament/queries/findByIdentifier.server.ts b/app/features/tournament/queries/findByIdentifier.server.ts index 0e2e12e42..e067f7625 100644 --- a/app/features/tournament/queries/findByIdentifier.server.ts +++ b/app/features/tournament/queries/findByIdentifier.server.ts @@ -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 & + Pick< + Tournament, + "id" | "format" | "mapPickingStyle" | "showMapListGenerator" + > & Pick & - Pick) - | null; + Pick) & { 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[] +) { + return Math.min(...rows.map((row) => row.startTime)); +} diff --git a/app/features/tournament/queries/findOwnTeam.server.ts b/app/features/tournament/queries/findOwnTeam.server.ts index efdfb9a96..d4e4c957e 100644 --- a/app/features/tournament/queries/findOwnTeam.server.ts +++ b/app/features/tournament/queries/findOwnTeam.server.ts @@ -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 & + Pick) + | null; export function findOwnTeam({ - calendarEventId, + tournamentId, userId, }: { - calendarEventId: number; + tournamentId: number; userId: number; }) { return stm.get({ - calendarEventId, + tournamentId, userId, }) as FindOwnTeam; } diff --git a/app/features/tournament/queries/findTeamByInviteCode.server.ts b/app/features/tournament/queries/findTeamByInviteCode.server.ts index 91d1e9243..5c60ac627 100644 --- a/app/features/tournament/queries/findTeamByInviteCode.server.ts +++ b/app/features/tournament/queries/findTeamByInviteCode.server.ts @@ -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; } diff --git a/app/features/tournament/queries/findTeamsByEventId.server.ts b/app/features/tournament/queries/findTeamsByTournamentId.server.ts similarity index 70% rename from app/features/tournament/queries/findTeamsByEventId.server.ts rename to app/features/tournament/queries/findTeamsByTournamentId.server.ts index 265badd44..8074450a0 100644 --- a/app/features/tournament/queries/findTeamsByEventId.server.ts +++ b/app/features/tournament/queries/findTeamsByTournamentId.server.ts @@ -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 & Pick< @@ -72,14 +82,15 @@ export interface FindTeamsByEventIdItem { | "discordName" | "plusTier" | "discordDiscriminator" + | "inGameName" > >; mapPool?: Array>; } -export type FindTeamsByEventId = Array; +export type FindTeamsByTournamentId = Array; -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; } diff --git a/app/features/tournament/queries/findTrustedPlayers.server.ts b/app/features/tournament/queries/findTrustedPlayers.server.ts new file mode 100644 index 000000000..26147ab78 --- /dev/null +++ b/app/features/tournament/queries/findTrustedPlayers.server.ts @@ -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 { + if (!teamId) return []; + return stm.all({ teamId, userId }); +} diff --git a/app/features/tournament/queries/giveTrust.server.ts b/app/features/tournament/queries/giveTrust.server.ts new file mode 100644 index 000000000..0f09ae97a --- /dev/null +++ b/app/features/tournament/queries/giveTrust.server.ts @@ -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 }); +} diff --git a/app/features/tournament/queries/hasTournamentStarted.server.ts b/app/features/tournament/queries/hasTournamentStarted.server.ts new file mode 100644 index 000000000..c74e3bba3 --- /dev/null +++ b/app/features/tournament/queries/hasTournamentStarted.server.ts @@ -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 })); +} diff --git a/app/features/tournament/queries/joinTeam.server.ts b/app/features/tournament/queries/joinLeaveTeam.server.ts similarity index 63% rename from app/features/tournament/queries/joinTeam.server.ts rename to app/features/tournament/queries/joinLeaveTeam.server.ts index 697a76f90..f9ed729f9 100644 --- a/app/features/tournament/queries/joinTeam.server.ts +++ b/app/features/tournament/queries/joinLeaveTeam.server.ts @@ -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 }); +}; diff --git a/app/features/tournament/queries/maxXPowers.server.ts b/app/features/tournament/queries/maxXPowers.server.ts new file mode 100644 index 000000000..a7258fc8f --- /dev/null +++ b/app/features/tournament/queries/maxXPowers.server.ts @@ -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); +}; diff --git a/app/features/tournament/queries/updateIsBeforeStart.server.ts b/app/features/tournament/queries/updateIsBeforeStart.server.ts deleted file mode 100644 index 951a11ea9..000000000 --- a/app/features/tournament/queries/updateIsBeforeStart.server.ts +++ /dev/null @@ -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 }); -} diff --git a/app/features/tournament/queries/updateShowMapListGenerator.server.ts b/app/features/tournament/queries/updateShowMapListGenerator.server.ts new file mode 100644 index 000000000..df79ba39e --- /dev/null +++ b/app/features/tournament/queries/updateShowMapListGenerator.server.ts @@ -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 }); +} diff --git a/app/features/tournament/queries/updateTeamInfo.server.ts b/app/features/tournament/queries/updateTeamInfo.server.ts index 71d15770f..95ebe78d3 100644 --- a/app/features/tournament/queries/updateTeamInfo.server.ts +++ b/app/features/tournament/queries/updateTeamInfo.server.ts @@ -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, }); } diff --git a/app/features/tournament/queries/updateTeamSeeds.server.ts b/app/features/tournament/queries/updateTeamSeeds.server.ts new file mode 100644 index 000000000..10c722802 --- /dev/null +++ b/app/features/tournament/queries/updateTeamSeeds.server.ts @@ -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, + }); + } + } +); diff --git a/app/features/tournament/routes/to.$id.admin.tsx b/app/features/tournament/routes/to.$id.admin.tsx index 0b597429f..f6bda7e6e 100644 --- a/app/features/tournament/routes/to.$id.admin.tsx +++ b/app/features/tournament/routes/to.$id.admin.tsx @@ -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(); + const user = useUser(); - const event = notFoundIfFalsy(findByIdentifier(eventId)); - notFoundIfFalsy(canAdminCalendarTOTools({ user, event })); + if (!canAdminTournament({ user, event: data.event })) { + return ; + } - // 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(); - const [eventStarted, setEventStarted] = React.useState( - Boolean(!data.event.isBeforeStart) + return ( +
    + + {isAdmin(user) ? : null} + +
    + + Edit event info + +
    +
    ); +} +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(); + const parentRouteData = useOutletContext(); + 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 ( + +
    + + +
    +
    + + +
    + {selectedTeam && selectedAction.inputs.includes("ROSTER_MEMBER") ? ( +
    + + +
    + ) : null} + {selectedAction.inputs.includes("USER") ? ( +
    + + +
    + ) : null} + + Go + +
    + ); +} + +function EnableMapList() { + const data = useOutletContext(); + 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 ( +
    + + +
    + ); +} + +function DownloadParticipants() { + const { t } = useTranslation(["tournament"]); + const data = useOutletContext(); + + 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 ( -
    -
    - - - - {t("tournament:admin.eventStarted.explanation")} - -
    -
    - -
    - -
    +
    + +
    + +
    ); diff --git a/app/features/tournament/routes/to.$id.index.tsx b/app/features/tournament/routes/to.$id.index.tsx index 3a26204c3..6d55955a9 100644 --- a/app/features/tournament/routes/to.$id.index.tsx +++ b/app/features/tournament/routes/to.$id.index.tsx @@ -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)); }; diff --git a/app/features/tournament/routes/to.$id.join.tsx b/app/features/tournament/routes/to.$id.join.tsx index 0f8d0f4b8..be972a147 100644 --- a/app/features/tournament/routes/to.$id.join.tsx +++ b/app/features/tournament/routes/to.$id.join.tsx @@ -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(); + const parentRouteData = useOutletContext(); const data = useLoaderData(); 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 (
    -
    {textPrompt()}
    +
    +
    {textPrompt()}
    + {validationStatus === "VALID" ? ( +
    + {" "} + +
    + ) : null} +
    {validationStatus === "VALID" ? ( Join ) : 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"; } diff --git a/app/features/tournament/routes/to.$id.maps.tsx b/app/features/tournament/routes/to.$id.maps.tsx index 19f40a74d..f54084c3b 100644 --- a/app/features/tournament/routes/to.$id.maps.tsx +++ b/app/features/tournament/routes/to.$id.maps.tsx @@ -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[]; }; -export default function TournamentToolsMapsPage() { +export default function TournamentMapsPage() { const user = useUser(); const { t } = useTranslation(["tournament"]); const actionData = useActionData<{ failed?: boolean }>(); - const data = useOutletContext(); + const data = useOutletContext(); const [bestOf, setBestOf] = useSearchParamState< (typeof TOURNAMENT)["AVAILABLE_BEST_OF"][number] @@ -85,7 +85,7 @@ export default function TournamentToolsMapsPage() { }; if (!data.mapListGeneratorAvailable) { - return ; + return ; } 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)} />
    @@ -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(); + const data = useOutletContext(); return (
    @@ -275,7 +274,7 @@ function BestOfRadios({ function MapList(props: Omit) { const { t } = useTranslation(["game-misc"]); - const data = useOutletContext(); + const data = useOutletContext(); let mapList: Array; diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index 7b99e6cbc..d966a1b37 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -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(); - const parentRouteData = useOutletContext(); + const parentRouteData = useOutletContext(); const teamRegularMemberOf = parentRouteData.teams.find((team) => team.members.some((member) => member.userId === user?.id && !member.isOwner) @@ -232,73 +291,240 @@ function RegistrationForms({ ownTeam?: NonNullable>["ownTeam"]; }) { const user = useUser(); + const parentRouteData = useOutletContext(); if (!user) return ; + const ownTeamFromList = resolveOwnedTeam({ + teams: parentRouteData.teams, + userId: user?.id, + }); + return (
    - - + + {ownTeam ? ( <> - ) : null}
    ); } -function RegisterToBracket() { - const parentRouteData = useOutletContext(); +function RegistrationProgress({ + checkedIn, + name, + members, + mapPool, +}: { + checkedIn?: boolean; + name?: string; + members?: unknown[]; + mapPool?: unknown[]; +}) { + const parentRouteData = useOutletContext(); + + 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 (
    -

    1. Register

    -
    - Register on{" "} - - {parentRouteData.event.bracketUrl} - +

    + Complete these steps to play +

    +
    +
    + {steps.map((step, i) => { + return ( +
    + {step.name} + {step.completed ? ( + + ) : ( + + )} +
    + ); + })} +
    + {!checkedIn ? ( + !step.completed).length === 1} + status={ + checkInIsOpen ? "OPEN" : checkInIsOver ? "OVER" : "UPCOMING" + } + startDate={checkInStartsDate} + endDate={checkInEndsDate} + /> + ) : null}
    +
    + Free editing of any information before the tournament starts allowed. +
    ); } -function TeamInfo({ - ownTeam, +function CheckIn({ + status, + canCheckIn, + startDate, + endDate, }: { - ownTeam?: NonNullable>["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 ( +
    + Check-in is open between {checkInStartsString} and {checkInEndsString} +
    + ); + } + + if (status === "OVER") { + return
    Check-in is over
    ; + } + + return ( + + + Check in + + + ); +} + +function TeamInfo({ + name, + prefersNotToHost = 0, +}: { + name?: string; + prefersNotToHost?: number; +}) { + const id = React.useId(); const fetcher = useFetcher(); return (
    -

    2. Team info

    +

    1. Team info

    -
    - - +
    +
    + + +
    +
    +
    + + +
    +
    - + Save
    -
    - Use the same name as on the bracket -
    ); } @@ -308,12 +534,13 @@ function FillRoster({ }: { ownTeam: NonNullable>["ownTeam"]; }) { + const data = useLoaderData(); const user = useUser(); - const parentRouteData = useOutletContext(); + const parentRouteData = useOutletContext(); 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 (
    -

    3. Fill roster

    +

    2. Fill roster

    -
    -
    - Share your invite link to add members: {inviteLink} + {playersAvailableToDirectlyAdd.length > 0 && !teamIsFull ? ( + <> + + OR + + ) : null} + {!teamIsFull ? ( +
    +
    + Share your invite link to add members: {inviteLink} +
    +
    + +
    -
    - -
    -
    -
    - {ownTeamMembers.map((member) => { + ) : null} +
    + {ownTeamMembers.map((member, i) => { return (
    {member.discordName} @@ -365,23 +623,61 @@ function FillRoster({
    ); })} + {new Array(optionalMembers).fill(null).map((_, i) => { + return ( +
    + ? +
    + ); + })}
    {showDeleteMemberSection ? ( ) : null}
    - 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}.
    ); } +function DirectlyAddPlayerSelect({ players }: { players: TrustedPlayer[] }) { + const fetcher = useFetcher(); + const id = React.useId(); + return ( + +
    + + +
    + + Add + +
    + ); +} + function DeleteMember({ members, }: { - members: Unpacked["members"]; + members: Unpacked["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(); + const parentRouteData = useOutletContext(); const fetcher = useFetcher(); const { counterpickMaps, handleCounterpickMapPoolSelect } = @@ -447,7 +745,7 @@ function CounterPickMapPoolPicker() { return (
    -

    4. Pick map pool

    +

    3. Pick map pool

    {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")} @@ -542,10 +844,6 @@ function CounterPickMapPoolPicker() { )}
    -
    - Picking a map pool is optional, but if you don't then you will be - playing on your opponent's picks. -
    ); } @@ -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 (
    @@ -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(); - - const checkInStartsString = isMounted - ? HACKY_resolveCheckInTime(parentRouteData.event).toLocaleTimeString( - i18n.language, - { - minute: "numeric", - hour: "numeric", - } - ) - : ""; - - return ( -
    -

    5. Check-in

    -
    - Check in starts at {checkInStartsString} here:{" "} - - {parentRouteData.event.bracketUrl} - -
    -
    - ); -} diff --git a/app/features/tournament/routes/to.$id.seeds.tsx b/app/features/tournament/routes/to.$id.seeds.tsx new file mode 100644 index 000000000..f7d652aaa --- /dev/null +++ b/app/features/tournament/routes/to.$id.seeds.tsx @@ -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(); + 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(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 = { + 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 ( +
    + + +
      +
    • +
      Seed
      +
      Name
      +
      + Players +
      +
    • + { + 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); + }); + } + }} + > + + {teamsSorted.map((team, i) => ( + + + + ))} + + + + {activeTeam && ( +
    • + +
    • + )} +
      +
      +
    +
    + ); +} + +function SeedAlert({ teamOrder }: { teamOrder: number[] }) { + const data = useOutletContext(); + 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 ( + + + + + {teamOrderChanged ? ( + <>You have unchanged changes to seeding + ) : showSuccess ? ( + <>Seeds saved successfully! + ) : ( + <>Drag teams to adjust their seeding + )} + {(!showSuccess || teamOrderChanged) && ( + + Save seeds + + )} + + + ); +} + +function RowContents({ + team, + seed, +}: { + team: TournamentLoaderTeam; + seed?: number; +}) { + const data = useLoaderData(); + + return ( + <> +
    {seed}
    +
    {team.name}
    +
    + {team.members.map((member) => { + const xPower = data.xPowers[member.userId]; + const lonely = + (!xPower && member.plusTier) || (!member.plusTier && xPower); + + return ( +
    +
    + {member.discordName} +
    + {member.plusTier ? ( +
    + + + {member.plusTier} +
    + ) : ( +
    + )} + {xPower ? ( +
    + {" "} + {xPower} +
    + ) : null} +
    + ); + })} +
    + + ); +} + +export const CatchBoundary = Catcher; diff --git a/app/features/tournament/routes/to.$id.teams.tsx b/app/features/tournament/routes/to.$id.teams.tsx index 74f0523f6..e3597b6c2 100644 --- a/app/features/tournament/routes/to.$id.teams.tsx +++ b/app/features/tournament/routes/to.$id.teams.tsx @@ -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(); +export default function TournamentTeamsPage() { + const data = useOutletContext(); return (
    @@ -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 ( -
    -
    - {t("tournament:teams.mapsPickedStatus")} - {hasMapPool() ? ( - - ) : ( - - )} -
    - -
    - ); + return ; })}
    ); @@ -56,7 +23,7 @@ export default function TournamentToolsTeamsPage() { function TeamWithRoster({ team, }: { - team: Pick; + team: Pick; }) { return (
    @@ -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 && diff --git a/app/features/tournament/routes/to.$id.tsx b/app/features/tournament/routes/to.$id.tsx index 0d36387bb..894032cbe 100644 --- a/app/features/tournament/routes/to.$id.tsx +++ b/app/features/tournament/routes/to.$id.tsx @@ -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; @@ -40,65 +61,84 @@ export const handle: SendouRouteHandle = { i18n: ["tournament"], }; -export type TournamentToolsTeam = Unpacked; -export type TournamentToolsLoaderData = SerializeFrom; +export type TournamentLoaderTeam = Unpacked; +export type TournamentLoaderData = SerializeFrom; 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(); + const location = useLocation(); + + const onBracketsPage = location.pathname.includes("brackets"); return ( -
    +
    - {data.event.isBeforeStart ? ( - {t("tournament:tabs.register")} + {!data.hasStarted ? ( + + {t("tournament:tabs.register")} + ) : null} + + Brackets + {data.mapListGeneratorAvailable ? ( {t("tournament:tabs.maps")} ) : null} {t("tournament:tabs.teams", { count: data.teams.length })} - {canAdminCalendarTOTools({ user, event: data.event }) && ( - {t("tournament:tabs.admin")} + {canAdminTournament({ user, event: data.event }) && + !data.hasStarted && Seeds} + {canAdminTournament({ user, event: data.event }) && ( + + {t("tournament:tabs.admin")} + )} diff --git a/app/features/tournament/tournament-constants.ts b/app/features/tournament/tournament-constants.ts index 130e1c879..b7776b24d 100644 --- a/app/features/tournament/tournament-constants.ts +++ b/app/features/tournament/tournament-constants.ts @@ -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, diff --git a/app/features/tournament/tournament-hooks.ts b/app/features/tournament/tournament-hooks.ts index a8d789e50..dd26669fe 100644 --- a/app/features/tournament/tournament-hooks.ts +++ b/app/features/tournament/tournament-hooks.ts @@ -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(); + const parentRouteData = useOutletContext(); const resolveInitialMapPool = (mode: RankedModeShort) => { const ownMapPool = diff --git a/app/features/tournament/tournament-schemas.server.ts b/app/features/tournament/tournament-schemas.server.ts index c0ab6f408..82c938a9d 100644 --- a/app/features/tournament/tournament-schemas.server.ts +++ b/app/features/tournament/tournament-schemas.server.ts @@ -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()), +}); diff --git a/app/features/tournament/tournament-utils.ts b/app/features/tournament/tournament-utils.ts index 1e9d94e19..de579422e 100644 --- a/app/features/tournament/tournament-utils.ts +++ b/app/features/tournament/tournament-utils.ts @@ -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; userId?: User["id"]; }) { if (typeof userId !== "number") return; @@ -22,7 +26,13 @@ export function resolveOwnedTeam({ ); } -export function idFromParams(params: Params) { +export function teamHasCheckedIn( + team: Pick +) { + return Boolean(team.checkedInAt); +} + +export function tournamentIdFromParams(params: Params) { const result = Number(params["id"]); invariant(!Number.isNaN(result), "id is not a number"); @@ -30,24 +40,36 @@ export function idFromParams(params: Params) { } export function modesIncluded( - event: TournamentToolsLoaderData["event"] + tournament: Pick ): 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 ) { - 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 ) { 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 +) { + return HACKY_resolveCheckInTime(event).getTime() < Date.now(); +} + +export function validateCanCheckIn({ + event, + team, +}: { + event: Pick; + 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; +} diff --git a/app/features/tournament/tournament.css b/app/features/tournament/tournament.css index 8711d9b1d..a1424966f 100644 --- a/app/features/tournament/tournament.css +++ b/app/features/tournament/tournament.css @@ -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; diff --git a/app/hooks/useAnimateListEntry.tsx b/app/hooks/useAnimateListEntry.ts similarity index 100% rename from app/hooks/useAnimateListEntry.tsx rename to app/hooks/useAnimateListEntry.ts diff --git a/app/hooks/useAutoRerender.ts b/app/hooks/useAutoRerender.ts new file mode 100644 index 000000000..acdae30cf --- /dev/null +++ b/app/hooks/useAutoRerender.ts @@ -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); + }; + }, []); +} diff --git a/app/hooks/useSearchParamState.tsx b/app/hooks/useSearchParamState.ts similarity index 100% rename from app/hooks/useSearchParamState.tsx rename to app/hooks/useSearchParamState.ts diff --git a/app/hooks/useTimeoutState.ts b/app/hooks/useTimeoutState.ts new file mode 100644 index 000000000..f8d5d5794 --- /dev/null +++ b/app/hooks/useTimeoutState.ts @@ -0,0 +1,33 @@ +import * as React from "react"; + +// TODO: fix causes memory leak +/** @link https://stackoverflow.com/a/64983274 */ +export const useTimeoutState = ( + defaultState: T +): [ + T, + (action: React.SetStateAction, opts?: { timeout: number }) => void +] => { + const [state, _setState] = React.useState(defaultState); + const [currentTimeoutId, setCurrentTimeoutId] = React.useState< + NodeJS.Timeout | undefined + >(); + + const setState = React.useCallback( + (action: React.SetStateAction, 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]; +}; diff --git a/app/modules/brackets-manager/README.md b/app/modules/brackets-manager/README.md new file mode 100644 index 000000000..8a6975699 --- /dev/null +++ b/app/modules/brackets-manager/README.md @@ -0,0 +1 @@ +Taken from https://github.com/Drarig29/brackets-manager.js diff --git a/app/modules/brackets-manager/base/getter.ts b/app/modules/brackets-manager/base/getter.ts new file mode 100644 index 000000000..17f59fef0 --- /dev/null +++ b/app/modules/brackets-manager/base/getter.ts @@ -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 { + 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."); + } +} diff --git a/app/modules/brackets-manager/base/updater.ts b/app/modules/brackets-manager/base/updater.ts new file mode 100644 index 000000000..0de0cb14a --- /dev/null +++ b/app/modules/brackets-manager/base/updater.ts @@ -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, + 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 + ): 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 = { + 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); + } +} diff --git a/app/modules/brackets-manager/create.ts b/app/modules/brackets-manager/create.ts new file mode 100644 index 000000000..8381a432e --- /dev/null +++ b/app/modules/brackets-manager/create.ts @@ -0,0 +1,1033 @@ +import type { + Group, + InputStage, + Match, + MatchGame, + Participant, + Round, + Seeding, + SeedOrdering, + Stage, +} from "brackets-model"; +import { defaultMinorOrdering, ordering } from "./ordering"; +import type { + Duel, + Storage, + OmitId, + ParticipantSlot, + StandardBracketResults, +} from "./types"; +import type { BracketsManager } from "."; +import * as helpers from "./helpers"; + +/** + * Creates a stage. + * + * @param this Instance of BracketsManager. + * @param stage The stage to create. + */ +export function create(this: BracketsManager, stage: InputStage): Stage { + const instance = new Create(this.storage, stage); + return instance.run(); +} + +export class Create { + private storage: Storage; + private stage: InputStage; + private readonly seedOrdering: SeedOrdering[]; + private updateMode: boolean; + private enableByesInUpdate: boolean; + private currentStageId!: number; + + /** + * Creates an instance of Create, which will handle the creation of the stage. + * + * @param storage The implementation of Storage. + * @param stage The stage to create. + */ + constructor(storage: Storage, stage: InputStage) { + this.storage = storage; + this.stage = stage; + this.stage.settings = this.stage.settings || {}; + this.seedOrdering = this.stage.settings.seedOrdering || []; + this.updateMode = false; + this.enableByesInUpdate = false; + + if (!this.stage.name) throw Error("You must provide a name for the stage."); + + if (!Number.isInteger(this.stage.tournamentId)) + throw Error("You must provide a tournament id for the stage."); + + if (stage.type === "round_robin") + this.stage.settings.roundRobinMode = + this.stage.settings.roundRobinMode || "simple"; + + if (stage.type === "single_elimination") + this.stage.settings.consolationFinal = + this.stage.settings.consolationFinal || false; + + if (stage.type === "double_elimination") + this.stage.settings.grandFinal = this.stage.settings.grandFinal || "none"; + + this.stage.settings.matchesChildCount = + this.stage.settings.matchesChildCount || 0; + } + + /** + * Run the creation process. + */ + public run(): Stage { + let stage: Stage; + + switch (this.stage.type) { + case "round_robin": + stage = this.roundRobin(); + break; + case "single_elimination": + stage = this.singleElimination(); + break; + case "double_elimination": + stage = this.doubleElimination(); + break; + default: + throw Error("Unknown stage type."); + } + + if (stage.id === -1) + throw Error("Something went wrong when creating the stage."); + + this.ensureSeedOrdering(stage.id); + + return stage; + } + + /** + * Enables the update mode. + * + * @param stageId ID of the stage. + * @param enableByes Whether to use BYEs or TBDs for `null` values in an input seeding. + */ + public setExisting(stageId: number, enableByes: boolean): void { + this.updateMode = true; + this.currentStageId = stageId; + this.enableByesInUpdate = enableByes; + } + + /** + * Creates a round-robin stage. + * + * Group count must be given. It will distribute participants in groups and rounds. + */ + private roundRobin(): Stage { + const groups = this.getRoundRobinGroups(); + const stage = this.createStage(); + + for (let i = 0; i < groups.length; i++) + this.createRoundRobinGroup(stage.id, i + 1, groups[i]); + + return stage; + } + + /** + * Creates a single elimination stage. + * + * One bracket and optionally a consolation final between semi-final losers. + */ + private singleElimination(): Stage { + if ( + Array.isArray(this.stage.settings?.seedOrdering) && + this.stage.settings?.seedOrdering.length !== 1 + ) + throw Error("You must specify one seed ordering method."); + + const slots = this.getSlots(); + const stage = this.createStage(); + const method = this.getStandardBracketFirstRoundOrdering(); + const ordered = ordering[method](slots); + + const { losers } = this.createStandardBracket(stage.id, 1, ordered); + this.createConsolationFinal(stage.id, losers); + + return stage; + } + + /** + * Creates a double elimination stage. + * + * One upper bracket (winner bracket, WB), one lower bracket (loser bracket, LB) and optionally a grand final + * between the winner of both bracket, which can be simple or double. + */ + private doubleElimination(): Stage { + if ( + this.stage.settings && + Array.isArray(this.stage.settings.seedOrdering) && + this.stage.settings.seedOrdering.length < 1 + ) + throw Error("You must specify at least one seed ordering method."); + + const slots = this.getSlots(); + const stage = this.createStage(); + const method = this.getStandardBracketFirstRoundOrdering(); + const ordered = ordering[method](slots); + + if (this.stage.settings?.skipFirstRound) + this.createDoubleEliminationSkipFirstRound(stage.id, ordered); + else this.createDoubleElimination(stage.id, ordered); + + return stage; + } + + /** + * Creates a double elimination stage with skip first round option. + * + * @param stageId ID of the stage. + * @param slots A list of slots. + */ + private createDoubleEliminationSkipFirstRound( + stageId: number, + slots: ParticipantSlot[] + ): void { + const { even: directInWb, odd: directInLb } = helpers.splitByParity(slots); + const { losers: losersWb, winner: winnerWb } = this.createStandardBracket( + stageId, + 1, + directInWb + ); + + // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain + if (helpers.isDoubleEliminationNecessary(this.stage.settings?.size!)) { + const winnerLb = this.createLowerBracket(stageId, 2, [ + directInLb, + ...losersWb, + ]); + this.createGrandFinal(stageId, winnerWb, winnerLb); + } + } + + /** + * Creates a double elimination stage. + * + * @param stageId ID of the stage. + * @param slots A list of slots. + */ + private createDoubleElimination( + stageId: number, + slots: ParticipantSlot[] + ): void { + const { losers: losersWb, winner: winnerWb } = this.createStandardBracket( + stageId, + 1, + slots + ); + + // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain + if (helpers.isDoubleEliminationNecessary(this.stage.settings?.size!)) { + const winnerLb = this.createLowerBracket(stageId, 2, losersWb); + this.createGrandFinal(stageId, winnerWb, winnerLb); + } + } + + /** + * Creates a round-robin group. + * + * This will make as many rounds as needed to let each participant match every other once. + * + * @param stageId ID of the parent stage. + * @param number Number in the stage. + * @param slots A list of slots. + */ + private createRoundRobinGroup( + stageId: number, + number: number, + slots: ParticipantSlot[] + ): void { + const groupId = this.insertGroup({ + stage_id: stageId, + number, + }); + + if (groupId === -1) throw Error("Could not insert the group."); + + const rounds = helpers.makeRoundRobinMatches( + slots, + this.stage.settings?.roundRobinMode + ); + + for (let i = 0; i < rounds.length; i++) + this.createRound(stageId, groupId, i + 1, rounds[0].length, rounds[i]); + } + + /** + * Creates a standard bracket, which is the only one in single elimination and the upper one in double elimination. + * + * This will make as many rounds as needed to end with one winner. + * + * @param stageId ID of the parent stage. + * @param number Number in the stage. + * @param slots A list of slots. + */ + private createStandardBracket( + stageId: number, + number: number, + slots: ParticipantSlot[] + ): StandardBracketResults { + const roundCount = helpers.getUpperBracketRoundCount(slots.length); + const groupId = this.insertGroup({ + stage_id: stageId, + number, + }); + + if (groupId === -1) throw Error("Could not insert the group."); + + let duels = helpers.makePairs(slots); + let roundNumber = 1; + + const losers: ParticipantSlot[][] = []; + + for (let i = roundCount - 1; i >= 0; i--) { + const matchCount = Math.pow(2, i); + duels = this.getCurrentDuels(duels, matchCount); + losers.push(duels.map(helpers.byeLoser)); + this.createRound(stageId, groupId, roundNumber++, matchCount, duels); + } + + return { losers, winner: helpers.byeWinner(duels[0]) }; + } + + /** + * Creates a lower bracket, alternating between major and minor rounds. + * + * - A major round is a regular round. + * - A minor round matches the previous (major) round's winners against upper bracket losers of the corresponding round. + * + * @param stageId ID of the parent stage. + * @param number Number in the stage. + * @param losers One list of losers per upper bracket round. + */ + private createLowerBracket( + stageId: number, + number: number, + losers: ParticipantSlot[][] + ): ParticipantSlot { + // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain + const participantCount = this.stage.settings?.size!; + const roundPairCount = helpers.getRoundPairCount(participantCount); + + let losersId = 0; + + const method = this.getMajorOrdering(participantCount); + const ordered = ordering[method](losers[losersId++]); + + const groupId = this.insertGroup({ + stage_id: stageId, + number, + }); + + if (groupId === -1) throw Error("Could not insert the group."); + + let duels = helpers.makePairs(ordered); + let roundNumber = 1; + + for (let i = 0; i < roundPairCount; i++) { + const matchCount = Math.pow(2, roundPairCount - i - 1); + + // Major round. + duels = this.getCurrentDuels(duels, matchCount, true); + this.createRound(stageId, groupId, roundNumber++, matchCount, duels); + + // Minor round. + const minorOrdering = this.getMinorOrdering( + participantCount, + i, + roundPairCount + ); + duels = this.getCurrentDuels( + duels, + matchCount, + false, + losers[losersId++], + minorOrdering + ); + this.createRound(stageId, groupId, roundNumber++, matchCount, duels); + } + + return helpers.byeWinnerToGrandFinal(duels[0]); + } + + /** + * Creates a bracket with rounds that only have 1 match each. Used for finals. + * + * @param stageId ID of the parent stage. + * @param number Number in the stage. + * @param duels A list of duels. + */ + private createUniqueMatchBracket( + stageId: number, + number: number, + duels: Duel[] + ): void { + const groupId = this.insertGroup({ + stage_id: stageId, + number, + }); + + if (groupId === -1) throw Error("Could not insert the group."); + + for (let i = 0; i < duels.length; i++) + this.createRound(stageId, groupId, i + 1, 1, [duels[i]]); + } + + /** + * Creates a round, which contain matches. + * + * @param stageId ID of the parent stage. + * @param groupId ID of the parent group. + * @param roundNumber Number in the group. + * @param matchCount Duel/match count. + * @param duels A list of duels. + */ + private createRound( + stageId: number, + groupId: number, + roundNumber: number, + matchCount: number, + duels: Duel[] + ): void { + const matchesChildCount = this.getMatchesChildCount(); + + const roundId = this.insertRound({ + number: roundNumber, + stage_id: stageId, + group_id: groupId, + }); + + if (roundId === -1) throw Error("Could not insert the round."); + + for (let i = 0; i < matchCount; i++) + this.createMatch( + stageId, + groupId, + roundId, + i + 1, + duels[i], + matchesChildCount + ); + } + + /** + * Creates a match, possibly with match games. + * + * - If `childCount` is 0, then there is no children. The score of the match is directly its intrinsic score. + * - If `childCount` is greater than 0, then the score of the match will automatically be calculated based on its child games. + * + * @param stageId ID of the parent stage. + * @param groupId ID of the parent group. + * @param roundId ID of the parent round. + * @param matchNumber Number in the round. + * @param opponents The two opponents matching against each other. + * @param childCount Child count for this match (number of games). + */ + private createMatch( + stageId: number, + groupId: number, + roundId: number, + matchNumber: number, + opponents: Duel, + childCount: number + ): void { + const opponent1 = helpers.toResultWithPosition(opponents[0]); + const opponent2 = helpers.toResultWithPosition(opponents[1]); + + // Round-robin matches can easily be removed. Prevent BYE vs. BYE matches. + if ( + this.stage.type === "round_robin" && + opponent1 === null && + opponent2 === null + ) + return; + + let existing: Match | null = null; + let status = helpers.getMatchStatus(opponents); + + if (this.updateMode) { + existing = this.storage.selectFirst("match", { + round_id: roundId, + number: matchNumber, + }); + + const currentChildCount = existing?.child_count; + childCount = + currentChildCount === undefined ? childCount : currentChildCount; + + if (existing) { + // Keep the most advanced status when updating a match. + const existingStatus = helpers.getMatchStatus(existing); + if (existingStatus > status) status = existingStatus; + } + } + + const parentId = this.insertMatch( + { + number: matchNumber, + stage_id: stageId, + group_id: groupId, + round_id: roundId, + child_count: childCount, + status: status, + opponent1, + opponent2, + }, + existing + ); + + if (parentId === -1) throw Error("Could not insert the match."); + + for (let i = 0; i < childCount; i++) { + const id = this.insertMatchGame({ + number: i + 1, + stage_id: stageId, + parent_id: parentId, + status: status, + opponent1: helpers.toResult(opponents[0]), + opponent2: helpers.toResult(opponents[1]), + }); + + if (id === -1) throw Error("Could not insert the match game."); + } + } + + /** + * Gets the duels for the current round based on the previous one. No ordering is done, it must be done beforehand for the first round. + * + * @param previousDuels Duels of the previous round. + * @param currentDuelCount Count of duels (matches) in the current round. + */ + private getCurrentDuels( + previousDuels: Duel[], + currentDuelCount: number + ): Duel[]; + + /** + * Gets the duels for a major round in the LB. No ordering is done, it must be done beforehand for the first round. + * + * @param previousDuels Duels of the previous round. + * @param currentDuelCount Count of duels (matches) in the current round. + * @param major Indicates that the round is a major round in the LB. + */ + private getCurrentDuels( + previousDuels: Duel[], + currentDuelCount: number, + major: true + ): Duel[]; + + /** + * Gets the duels for a minor round in the LB. Ordering is done. + * + * @param previousDuels Duels of the previous round. + * @param currentDuelCount Count of duels (matches) in the current round. + * @param major Indicates that the round is a minor round in the LB. + * @param losers The losers going from the WB. + * @param method The ordering method to apply to the losers. + */ + private getCurrentDuels( + previousDuels: Duel[], + currentDuelCount: number, + major: false, + losers: ParticipantSlot[], + method?: SeedOrdering + ): Duel[]; + + /** + * Generic implementation. + * + * @param previousDuels Always given. + * @param currentDuelCount Always given. + * @param major Only for loser bracket. + * @param losers Only for minor rounds of loser bracket. + * @param method Only for minor rounds. Ordering method for the losers. + */ + private getCurrentDuels( + previousDuels: Duel[], + currentDuelCount: number, + major?: boolean, + losers?: ParticipantSlot[], + method?: SeedOrdering + ): Duel[] { + if ( + (major === undefined || major) && + previousDuels.length === currentDuelCount + ) { + // First round. + return previousDuels; + } + + if (major === undefined || major) { + // From major to major (WB) or minor to major (LB). + return helpers.transitionToMajor(previousDuels); + } + + // From major to minor (LB). + // Losers and method won't be undefined. + return helpers.transitionToMinor(previousDuels, losers!, method); + } + + /** + * Returns a list of slots. + * - If `seeding` was given, inserts them in the storage. + * - If `size` was given, only returns a list of empty slots. + * + * @param positions An optional list of positions (seeds) for a manual ordering. + */ + public getSlots(positions?: number[]): ParticipantSlot[] { + const size = this.stage.settings?.size || this.stage.seeding?.length || 0; + helpers.ensureValidSize(this.stage.type, size); + + if (size && !this.stage.seeding) + return Array.from(Array(size), (_: ParticipantSlot, i) => ({ + id: null, + position: i + 1, + })); + + if (!this.stage.seeding) + throw Error("Either size or seeding must be given."); + + this.stage.settings = { + ...this.stage.settings, + size, // Always set the size. + }; + + helpers.ensureNoDuplicates(this.stage.seeding); + this.stage.seeding = helpers.fixSeeding(this.stage.seeding, size); + + if (this.stage.type !== "round_robin" && this.stage.settings.balanceByes) + this.stage.seeding = helpers.balanceByes( + this.stage.seeding, + this.stage.settings.size + ); + + if (helpers.isSeedingWithIds(this.stage.seeding)) + return this.getSlotsUsingIds(this.stage.seeding, positions); + + return this.getSlotsUsingNames(this.stage.seeding, positions); + } + + /** + * Returns the list of slots with a seeding containing names. Participants may be added to database. + * + * @param seeding The seeding (names). + * @param positions An optional list of positions (seeds) for a manual ordering. + */ + private getSlotsUsingNames( + seeding: Seeding, + positions?: number[] + ): ParticipantSlot[] { + const participants = helpers.extractParticipantsFromSeeding( + this.stage.tournamentId, + seeding + ); + + if (!this.registerParticipants(participants)) + throw Error("Error registering the participants."); + + // Get participants back with IDs. + const added = this.storage.select("participant", { + tournament_id: this.stage.tournamentId, + }); + if (!added) throw Error("Error getting registered participant."); + + return helpers.mapParticipantsNamesToDatabase(seeding, added, positions); + } + + /** + * Returns the list of slots with a seeding containing IDs. No database mutation. + * + * @param seeding The seeding (IDs). + * @param positions An optional list of positions (seeds) for a manual ordering. + */ + private getSlotsUsingIds( + seeding: Seeding, + positions?: number[] + ): ParticipantSlot[] { + const participants = this.storage.select("participant", { + tournament_id: this.stage.tournamentId, + }); + if (!participants) throw Error("No available participants."); + + return helpers.mapParticipantsIdsToDatabase( + seeding, + participants, + positions + ); + } + + /** + * Gets the current stage number based on existing stages. + */ + private getStageNumber(): number { + const stages = this.storage.select("stage", { + tournament_id: this.stage.tournamentId, + }); + const stageNumbers = stages?.map((stage) => stage.number); + + if (this.stage.number !== undefined) { + if (stageNumbers?.includes(this.stage.number)) + throw Error("The given stage number already exists."); + + return this.stage.number; + } + + if (!stageNumbers?.length) return 1; + + const maxNumber = Math.max(...stageNumbers); + return maxNumber + 1; + } + + /** + * Safely gets `matchesChildCount` in the stage input settings. + */ + private getMatchesChildCount(): number { + if (!this.stage.settings?.matchesChildCount) return 0; + + return this.stage.settings.matchesChildCount; + } + + /** + * Safely gets an ordering by its index in the stage input settings. + * + * @param orderingIndex Index of the ordering. + * @param stageType A value indicating if the method should be a group method or not. + * @param defaultMethod The default method to use if not given. + */ + private getOrdering( + orderingIndex: number, + stageType: "elimination" | "groups", + defaultMethod: SeedOrdering + ): SeedOrdering { + if (!this.stage.settings?.seedOrdering) { + this.seedOrdering.push(defaultMethod); + return defaultMethod; + } + + const method = this.stage.settings.seedOrdering[orderingIndex]; + if (!method) { + this.seedOrdering.push(defaultMethod); + return defaultMethod; + } + + if (stageType === "elimination" && method.match(/^groups\./)) + throw Error( + "You must specify a seed ordering method without a 'groups' prefix" + ); + + if ( + stageType === "groups" && + method !== "natural" && + !method.match(/^groups\./) + ) + throw Error( + "You must specify a seed ordering method with a 'groups' prefix" + ); + + return method; + } + + /** + * Gets the duels in groups for a round-robin stage. + */ + private getRoundRobinGroups(): ParticipantSlot[][] { + if ( + this.stage.settings?.groupCount === undefined || + !Number.isInteger(this.stage.settings.groupCount) + ) + throw Error("You must specify a group count for round-robin stages."); + + if (this.stage.settings.groupCount <= 0) + throw Error("You must provide a strictly positive group count."); + + if (this.stage.settings?.manualOrdering) { + if ( + this.stage.settings?.manualOrdering.length !== + this.stage.settings?.groupCount + ) + throw Error( + "Group count in the manual ordering does not correspond to the given group count." + ); + + const positions = this.stage.settings?.manualOrdering.flat(); + const slots = this.getSlots(positions); + + return helpers.makeGroups(slots, this.stage.settings.groupCount); + } + + if ( + Array.isArray(this.stage.settings.seedOrdering) && + this.stage.settings.seedOrdering.length !== 1 + ) + throw Error("You must specify one seed ordering method."); + + const method = this.getRoundRobinOrdering(); + const slots = this.getSlots(); + const ordered = ordering[method](slots, this.stage.settings.groupCount); + return helpers.makeGroups(ordered, this.stage.settings.groupCount); + } + + /** + * Returns the ordering method for the groups in a round-robin stage. + */ + public getRoundRobinOrdering(): SeedOrdering { + return this.getOrdering(0, "groups", "groups.effort_balanced"); + } + + /** + * Returns the ordering method for the first round of the upper bracket of an elimination stage. + */ + public getStandardBracketFirstRoundOrdering(): SeedOrdering { + return this.getOrdering(0, "elimination", "inner_outer"); + } + + /** + * Safely gets the only major ordering for the lower bracket. + * + * @param participantCount Number of participants in the stage. + */ + private getMajorOrdering(participantCount: number): SeedOrdering { + return this.getOrdering( + 1, + "elimination", + defaultMinorOrdering[participantCount]?.[0] || "natural" + ); + } + + /** + * Safely gets a minor ordering for the lower bracket by its index. + * + * @param participantCount Number of participants in the stage. + * @param index Index of the minor round. + * @param minorRoundCount Number of minor rounds. + */ + private getMinorOrdering( + participantCount: number, + index: number, + minorRoundCount: number + ): SeedOrdering | undefined { + // No ordering for the last minor round. There is only one participant to order. + if (index === minorRoundCount - 1) return undefined; + + return this.getOrdering( + 2 + index, + "elimination", + defaultMinorOrdering[participantCount]?.[1 + index] || "natural" + ); + } + + /** + * Inserts a stage or finds an existing one. + * + * @param stage The stage to insert. + */ + private insertStage(stage: OmitId): number { + let existing: Stage | null = null; + + if (this.updateMode) + existing = this.storage.select("stage", this.currentStageId); + + if (!existing) return this.storage.insert("stage", stage); + + return existing.id; + } + + /** + * Inserts a group or finds an existing one. + * + * @param group The group to insert. + */ + private insertGroup(group: OmitId): number { + let existing: Group | null = null; + + if (this.updateMode) { + existing = this.storage.selectFirst("group", { + stage_id: group.stage_id, + number: group.number, + }); + } + + if (!existing) return this.storage.insert("group", group); + + return existing.id; + } + + /** + * Inserts a round or finds an existing one. + * + * @param round The round to insert. + */ + private insertRound(round: OmitId): number { + let existing: Round | null = null; + + if (this.updateMode) { + existing = this.storage.selectFirst("round", { + group_id: round.group_id, + number: round.number, + }); + } + + if (!existing) return this.storage.insert("round", round); + + return existing.id; + } + + /** + * Inserts a match or updates an existing one. + * + * @param match The match to insert. + * @param existing An existing match corresponding to the current one. + */ + private insertMatch(match: OmitId, existing: Match | null): number { + if (!existing) return this.storage.insert("match", match); + + const updated = helpers.getUpdatedMatchResults( + match, + existing, + this.enableByesInUpdate + ) as Match; + if (!this.storage.update("match", existing.id, updated)) + throw Error("Could not update the match."); + + return existing.id; + } + + /** + * Inserts a match game or finds an existing one (and updates it). + * + * @param matchGame The match game to insert. + */ + private insertMatchGame(matchGame: OmitId): number { + let existing: MatchGame | null = null; + + if (this.updateMode) { + existing = this.storage.selectFirst("match_game", { + parent_id: matchGame.parent_id, + number: matchGame.number, + }); + } + + if (!existing) return this.storage.insert("match_game", matchGame); + + const updated = helpers.getUpdatedMatchResults( + matchGame, + existing, + this.enableByesInUpdate + ) as MatchGame; + if (!this.storage.update("match_game", existing.id, updated)) + throw Error("Could not update the match game."); + + return existing.id; + } + + /** + * Inserts missing participants. + * + * @param participants The list of participants to process. + */ + private registerParticipants(participants: OmitId[]): boolean { + const existing = this.storage.select("participant", { + tournament_id: this.stage.tournamentId, + }); + + // Insert all if nothing. + if (!existing || existing.length === 0) + return this.storage.insert("participant", participants); + + // Insert only missing otherwise. + for (const participant of participants) { + if (existing.some((value) => value.name === participant.name)) continue; + + const result = this.storage.insert("participant", participant); + if (result === -1) return false; + } + + return true; + } + + /** + * Creates a new stage. + */ + private createStage(): Stage { + const number = this.getStageNumber(); + const stage: OmitId = { + tournament_id: this.stage.tournamentId, + name: this.stage.name, + type: this.stage.type, + number: number, + settings: this.stage.settings || {}, + }; + + const stageId = this.insertStage(stage); + + if (stageId === -1) throw Error("Could not insert the stage."); + + return { ...stage, id: stageId }; + } + + /** + * Creates a consolation final for the semi final losers of a single elimination stage. + * + * @param stageId ID of the stage. + * @param losers The semi final losers who will play the consolation final. + */ + private createConsolationFinal( + stageId: number, + losers: ParticipantSlot[][] + ): void { + if (!this.stage.settings?.consolationFinal) return; + + const semiFinalLosers = losers[losers.length - 2] as Duel; + this.createUniqueMatchBracket(stageId, 2, [semiFinalLosers]); + } + + /** + * Creates a grand final (none, simple or double) for winners of both bracket in a double elimination stage. + * + * @param stageId ID of the stage. + * @param winnerWb The winner of the winner bracket. + * @param winnerLb The winner of the loser bracket. + */ + private createGrandFinal( + stageId: number, + winnerWb: ParticipantSlot, + winnerLb: ParticipantSlot + ): void { + // No Grand Final by default. + const grandFinal = this.stage.settings?.grandFinal; + if (grandFinal === "none") return; + + // One duel by default. + const finalDuels: Duel[] = [[winnerWb, winnerLb]]; + + // Second duel. + if (grandFinal === "double") finalDuels.push([{ id: null }, { id: null }]); + + this.createUniqueMatchBracket(stageId, 3, finalDuels); + } + + /** + * Ensures that the seed ordering list is stored even if it was not given in the first place. + * + * @param stageId ID of the stage. + */ + private ensureSeedOrdering(stageId: number): void { + if (this.stage.settings?.seedOrdering?.length === this.seedOrdering.length) + return; + + const stage = this.storage.select("stage", stageId); + if (!stage) throw Error("Stage not found."); + + stage.settings = { + ...stage.settings, + seedOrdering: this.seedOrdering, + }; + + if (!this.storage.update("stage", stageId, stage)) + throw Error("Could not update the stage."); + } +} diff --git a/app/modules/brackets-manager/delete.ts b/app/modules/brackets-manager/delete.ts new file mode 100644 index 000000000..210317d47 --- /dev/null +++ b/app/modules/brackets-manager/delete.ts @@ -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); + } +} diff --git a/app/modules/brackets-manager/find.ts b/app/modules/brackets-manager/find.ts new file mode 100644 index 000000000..b5bf17134 --- /dev/null +++ b/app/modules/brackets-manager/find.ts @@ -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 { + return this.findMatchGame(game); + } +} diff --git a/app/modules/brackets-manager/get.ts b/app/modules/brackets-manager/get.ts new file mode 100644 index 000000000..bdb77b672 --- /dev/null +++ b/app/modules/brackets-manager/get.ts @@ -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; +}; diff --git a/app/modules/brackets-manager/helpers.ts b/app/modules/brackets-manager/helpers.ts new file mode 100644 index 000000000..7a098f033 --- /dev/null +++ b/app/modules/brackets-manager/helpers.ts @@ -0,0 +1,2022 @@ +import type { + GrandFinalType, + Match, + MatchGame, + MatchResults, + Participant, + ParticipantResult, + CustomParticipant, + Result, + RoundRobinMode, + Seeding, + SeedOrdering, + Stage, + StageType, + GroupType, +} from "brackets-model"; +import { Status } from "brackets-model"; + +import type { + Database, + DeepPartial, + Duel, + FinalStandingsItem, + IdMapping, + Nullable, + OmitId, + ParitySplit, + ParticipantSlot, + Scores, + Side, +} from "./types"; +import { ordering } from "./ordering"; + +/** + * Splits an array of objects based on their values at a given key. + * + * @param objects The array to split. + * @param key The key of T. + */ +export function splitBy< + T extends Record, + K extends keyof T, + U extends Record +>(objects: U[], key: K): U[][] { + const map = {} as Record; + + for (const obj of objects) { + const commonValue = obj[key]; + + if (!map[commonValue]) map[commonValue] = []; + + map[commonValue].push(obj); + } + + return Object.values(map); +} + +/** + * Splits an array in two parts: one with even indices and the other with odd indices. + * + * @param array The array to split. + */ +export function splitByParity(array: T[]): ParitySplit { + return { + even: array.filter((_, i) => i % 2 === 0), + odd: array.filter((_, i) => i % 2 === 1), + }; +} + +/** + * Makes a list of rounds containing the matches of a round-robin group. + * + * @param participants The participants to distribute. + * @param mode The round-robin mode. + */ +export function makeRoundRobinMatches( + participants: T[], + mode: RoundRobinMode = "simple" +): [T, T][][] { + const distribution = makeRoundRobinDistribution(participants); + + if (mode === "simple") return distribution; + + // Reverse rounds and their content. + const symmetry = distribution.map((round) => [...round].reverse()).reverse(); + + return [...distribution, ...symmetry]; +} + +/** + * Distributes participants in rounds for a round-robin group. + * + * Conditions: + * - Each participant plays each other once. + * - Each participant plays once in each round. + * + * @param participants The participants to distribute. + */ +export function makeRoundRobinDistribution(participants: T[]): [T, T][][] { + const n = participants.length; + const n1 = n % 2 === 0 ? n : n + 1; + const roundCount = n1 - 1; + const matchPerRound = n1 / 2; + + const rounds: [T, T][][] = []; + + for (let roundId = 0; roundId < roundCount; roundId++) { + const matches: [T, T][] = []; + + for (let matchId = 0; matchId < matchPerRound; matchId++) { + if (matchId === 0 && n % 2 === 1) continue; + + const opponentsIds = [ + (roundId - matchId - 1 + n1) % (n1 - 1), + matchId === 0 ? n1 - 1 : (roundId + matchId) % (n1 - 1), + ]; + + matches.push([ + participants[opponentsIds[0]], + participants[opponentsIds[1]], + ]); + } + + rounds.push(matches); + } + + return rounds; +} + +/** + * A helper to assert our generated round-robin is correct. + * + * @param input The input seeding. + * @param output The resulting distribution of seeds in groups. + */ +export function assertRoundRobin( + input: number[], + output: [number, number][][] +): void { + const n = input.length; + const matchPerRound = Math.floor(n / 2); + const roundCount = n % 2 === 0 ? n - 1 : n; + + if (output.length !== roundCount) throw Error("Round count is wrong"); + if (!output.every((round) => round.length === matchPerRound)) + throw Error("Not every round has the good number of matches"); + + const checkAllOpponents = Object.fromEntries( + input.map((element) => [element, new Set()]) + ) as Record>; + + for (const round of output) { + const checkUnique = new Set(); + + for (const match of round) { + if (match.length !== 2) throw Error("One match is not a pair"); + + if (checkUnique.has(match[0])) + throw Error("This team is already playing"); + checkUnique.add(match[0]); + + if (checkUnique.has(match[1])) + throw Error("This team is already playing"); + checkUnique.add(match[1]); + + if (checkAllOpponents[match[0]].has(match[1])) + throw Error("The team has already matched this team"); + checkAllOpponents[match[0]].add(match[1]); + + if (checkAllOpponents[match[1]].has(match[0])) + throw Error("The team has already matched this team"); + checkAllOpponents[match[1]].add(match[0]); + } + } +} + +/** + * Distributes elements in groups of equal size. + * + * @param elements A list of elements to distribute in groups. + * @param groupCount The group count. + */ +export function makeGroups(elements: T[], groupCount: number): T[][] { + const groupSize = Math.ceil(elements.length / groupCount); + const result: T[][] = []; + + for (let i = 0; i < elements.length; i++) { + if (i % groupSize === 0) result.push([]); + + result[result.length - 1].push(elements[i]); + } + + return result; +} + +/** + * Balances BYEs to prevents having BYE against BYE in matches. + * + * @param seeding The seeding of the stage. + * @param participantCount The number of participants in the stage. + */ +export function balanceByes( + seeding: Seeding, + participantCount?: number +): Seeding { + seeding = seeding.filter((v) => v !== null); + + participantCount = participantCount || getNearestPowerOfTwo(seeding.length); + + if (seeding.length < participantCount / 2) { + const flat = seeding.flatMap((v) => [v, null]); + return setArraySize(flat, participantCount, null); + } + + const nonNullCount = seeding.length; + const nullCount = participantCount - nonNullCount; + const againstEachOther = seeding + .slice(0, nonNullCount - nullCount) + .filter((_, i) => i % 2 === 0) + .map((_, i) => [seeding[2 * i], seeding[2 * i + 1]]); + const againstNull = seeding + .slice(nonNullCount - nullCount, nonNullCount) + .map((v) => [v, null]); + const flat = [...againstEachOther.flat(), ...againstNull.flat()]; + + return setArraySize(flat, participantCount, null); +} + +/** + * Normalizes IDs in a database. + * + * All IDs (and references to them) are remapped to consecutive IDs starting from 0. + * + * @param data Data to normalize. + */ +export function normalizeIds(data: Database): Database { + const mappings = { + participant: makeNormalizedIdMapping(data.participant), + stage: makeNormalizedIdMapping(data.stage), + group: makeNormalizedIdMapping(data.group), + round: makeNormalizedIdMapping(data.round), + match: makeNormalizedIdMapping(data.match), + match_game: makeNormalizedIdMapping(data.match_game), + }; + + return { + participant: data.participant.map((value) => ({ + ...value, + id: mappings.participant[value.id], + })), + stage: data.stage.map((value) => ({ + ...value, + id: mappings.stage[value.id], + })), + group: data.group.map((value) => ({ + ...value, + id: mappings.group[value.id], + stage_id: mappings.stage[value.stage_id], + })), + round: data.round.map((value) => ({ + ...value, + id: mappings.round[value.id], + stage_id: mappings.stage[value.stage_id], + group_id: mappings.group[value.group_id], + })), + match: data.match.map((value) => ({ + ...value, + id: mappings.match[value.id], + stage_id: mappings.stage[value.stage_id], + group_id: mappings.group[value.group_id], + round_id: mappings.round[value.round_id], + opponent1: normalizeParticipant(value.opponent1, mappings.participant), + opponent2: normalizeParticipant(value.opponent2, mappings.participant), + })), + match_game: data.match_game.map((value) => ({ + ...value, + id: mappings.match_game[value.id], + stage_id: mappings.stage[value.stage_id], + parent_id: mappings.match[value.parent_id], + opponent1: normalizeParticipant(value.opponent1, mappings.participant), + opponent2: normalizeParticipant(value.opponent2, mappings.participant), + })), + }; +} + +/** + * Makes a mapping between old IDs and new normalized IDs. + * + * @param elements A list of elements with IDs. + */ +export function makeNormalizedIdMapping(elements: { id: number }[]): IdMapping { + let currentId = 0; + + return elements.reduce( + (acc, current) => ({ + ...acc, + [current.id]: currentId++, + }), + {} + ) as IdMapping; +} + +/** + * Apply a normalizing mapping to a participant. + * + * @param participant The participant. + * @param mapping The mapping of IDs. + */ +export function normalizeParticipant( + participant: ParticipantResult | null, + mapping: IdMapping +): ParticipantResult | null { + if (participant === null) return null; + + return { + ...participant, + id: participant.id !== null ? mapping[participant.id] : null, + }; +} + +/** + * Sets the size of an array with a placeholder if the size is bigger. + * + * @param array The original array. + * @param length The new length. + * @param placeholder A placeholder to use to fill the empty space. + */ +export function setArraySize( + array: T[], + length: number, + placeholder: T +): T[] { + return Array.from(Array(length), (_, i) => array[i] || placeholder); +} + +/** + * Makes pairs with each element and its next one. + * + * @example [1, 2, 3, 4] --> [[1, 2], [3, 4]] + * @param array A list of elements. + */ +export function makePairs(array: T[]): [T, T][] { + return array + .map((_, i) => (i % 2 === 0 ? [array[i], array[i + 1]] : [])) + .filter((v): v is [T, T] => v.length === 2); +} + +/** + * Ensures that a list of elements has an even size. + * + * @param array A list of elements. + */ +export function ensureEvenSized(array: T[]): void { + if (array.length % 2 === 1) throw Error("Array size must be even."); +} + +/** + * Ensures there are no duplicates in a list of elements. + * + * @param array A list of elements. + */ +export function ensureNoDuplicates(array: Nullable[]): void { + const nonNull = getNonNull(array); + const unique = nonNull.filter((item, index) => { + const stringifiedItem = JSON.stringify(item); + return ( + nonNull.findIndex((obj) => JSON.stringify(obj) === stringifiedItem) === + index + ); + }); + + if (unique.length < nonNull.length) + throw new Error("The seeding has a duplicate participant."); +} + +/** + * Ensures that two lists of elements have the same size. + * + * @param left The first list of elements. + * @param right The second list of elements. + */ +export function ensureEquallySized(left: T[], right: T[]): void { + if (left.length !== right.length) throw Error("Arrays' size must be equal."); +} + +/** + * Fixes the seeding by enlarging it if it's not complete. + * + * @param seeding The seeding of the stage. + * @param participantCount The number of participants in the stage. + */ +export function fixSeeding( + seeding: Seeding, + participantCount: number +): Seeding { + if (seeding.length > participantCount) + throw Error( + "The seeding has more participants than the size of the stage." + ); + + if (seeding.length < participantCount) + return setArraySize(seeding, participantCount, null); + + return seeding; +} + +/** + * Ensures that the participant count is valid. + * + * @param stageType Type of the stage to test. + * @param participantCount The number to test. + */ +export function ensureValidSize( + stageType: StageType, + participantCount: number +): void { + if (participantCount === 0) + throw Error( + "Impossible to create an empty stage. If you want an empty seeding, just set the size of the stage." + ); + + if (participantCount < 2) + throw Error("Impossible to create a stage with less than 2 participants."); + + if (stageType === "round_robin") { + // Round robin supports any number of participants. + return; + } + + if (!Number.isInteger(Math.log2(participantCount))) + throw Error( + "The library only supports a participant count which is a power of two." + ); +} + +/** + * Ensures that a match scores aren't tied. + * + * @param scores Two numbers which are scores. + */ +export function ensureNotTied(scores: [number, number]): void { + if (scores[0] === scores[1]) + throw Error(`${scores[0]} and ${scores[1]} are tied. It cannot be.`); +} + +/** + * Converts a TBD to a BYE. + * + * @param slot The slot to convert. + */ +export function convertTBDtoBYE(slot: ParticipantSlot): ParticipantSlot { + if (slot === null) return null; // Already a BYE. + if (slot?.id === null) return null; // It's a TBD: make it a BYE. + + return slot; // It's a determined participant. +} + +/** + * Converts a participant slot to a result stored in storage. + * + * @param slot A participant slot. + */ +export function toResult(slot: ParticipantSlot): ParticipantSlot { + return ( + slot && { + id: slot.id, + } + ); +} + +/** + * Converts a participant slot to a result stored in storage, with the position the participant is coming from. + * + * @param slot A participant slot. + */ +export function toResultWithPosition(slot: ParticipantSlot): ParticipantSlot { + return ( + slot && { + id: slot.id, + position: slot.position, + } + ); +} + +/** + * Returns the winner of a match. + * + * @param match The match. + */ +export function getWinner(match: MatchResults): ParticipantSlot { + const winnerSide = getMatchResult(match); + if (!winnerSide) return null; + return match[winnerSide]; +} + +/** + * Returns the loser of a match. + * + * @param match The match. + */ +export function getLoser(match: MatchResults): ParticipantSlot { + const winnerSide = getMatchResult(match); + if (!winnerSide) return null; + return match[getOtherSide(winnerSide)]; +} + +/** + * Returns the pre-computed winner for a match because of BYEs. + * + * @param opponents Two opponents. + */ +export function byeWinner(opponents: Duel): ParticipantSlot { + if (opponents[0] === null && opponents[1] === null) + // Double BYE. + return null; // BYE. + + if (opponents[0] === null && opponents[1] !== null) + // opponent1 BYE. + return { id: opponents[1]!.id }; // opponent2. + + if (opponents[0] !== null && opponents[1] === null) + // opponent2 BYE. + return { id: opponents[0]!.id }; // opponent1. + + return { id: null }; // Normal. +} + +/** + * Returns the pre-computed winner for a match because of BYEs in a lower bracket. + * + * @param opponents Two opponents. + */ +export function byeWinnerToGrandFinal(opponents: Duel): ParticipantSlot { + const winner = byeWinner(opponents); + if (winner) winner.position = 1; + return winner; +} + +/** + * Returns the pre-computed loser for a match because of BYEs. + * + * Only used for loser bracket. + * + * @param opponents Two opponents. + * @param index The index of the duel in the round. + */ +export function byeLoser(opponents: Duel, index: number): ParticipantSlot { + if (opponents[0] === null || opponents[1] === null) + // At least one BYE. + return null; // BYE. + + return { id: null, position: index + 1 }; // Normal. +} + +/** + * Returns the winner side or `null` if no winner. + * + * @param match A match's results. + */ +export function getMatchResult(match: MatchResults): Side | null { + if (!isMatchCompleted(match)) return null; + + if (isMatchDrawCompleted(match)) return null; + + if (match.opponent1 === null && match.opponent2 === null) return null; + + let winner: Side | null = null; + + if ( + match.opponent1?.result === "win" || + match.opponent2 === null || + match.opponent2.forfeit + ) + winner = "opponent1"; + + if ( + match.opponent2?.result === "win" || + match.opponent1 === null || + match.opponent1.forfeit + ) { + if (winner !== null) throw Error("There are two winners."); + winner = "opponent2"; + } + + return winner; +} + +/** + * Finds a position in a list of matches. + * + * @param matches A list of matches to search into. + * @param position The position to find. + */ +export function findPosition( + matches: Match[], + position: number +): ParticipantSlot { + for (const match of matches) { + if (match.opponent1?.position === position) return match.opponent1; + + if (match.opponent2?.position === position) return match.opponent2; + } + + return null; +} + +/** + * Checks if a participant is involved in a given match. + * + * @param match A match. + * @param participantId ID of a participant. + */ +export function isParticipantInMatch( + match: MatchResults, + participantId: number +): boolean { + return [match.opponent1, match.opponent2].some( + (m) => m?.id === participantId + ); +} + +/** + * Gets the side where the winner of the given match will go in the next match. + * + * @param matchNumber Number of the match. + */ +export function getSide(matchNumber: number): Side { + return matchNumber % 2 === 1 ? "opponent1" : "opponent2"; +} + +/** + * Gets the other side of a match. + * + * @param side The side that we don't want. + */ +export function getOtherSide(side: Side): Side { + return side === "opponent1" ? "opponent2" : "opponent1"; +} + +/** + * Checks if a match is started. + * + * @param match Partial match results. + */ +export function isMatchStarted(match: DeepPartial): boolean { + return ( + match.opponent1?.score !== undefined || match.opponent2?.score !== undefined + ); +} + +/** + * Checks if a match is completed. + * + * @param match Partial match results. + */ +export function isMatchCompleted(match: DeepPartial): boolean { + return ( + isMatchByeCompleted(match) || + isMatchForfeitCompleted(match) || + isMatchResultCompleted(match) + ); +} + +/** + * Checks if a match is completed because of a forfeit. + * + * @param match Partial match results. + */ +export function isMatchForfeitCompleted( + match: DeepPartial +): boolean { + return ( + match.opponent1?.forfeit !== undefined || + match.opponent2?.forfeit !== undefined + ); +} + +/** + * Checks if a match is completed because of a either a draw or a win. + * + * @param match Partial match results. + */ +export function isMatchResultCompleted( + match: DeepPartial +): boolean { + return isMatchDrawCompleted(match) || isMatchWinCompleted(match); +} + +/** + * Checks if a match is completed because of a draw. + * + * @param match Partial match results. + */ +export function isMatchDrawCompleted( + match: DeepPartial +): boolean { + return ( + match.opponent1?.result === "draw" && match.opponent2?.result === "draw" + ); +} + +/** + * Checks if a match is completed because of a win. + * + * @param match Partial match results. + */ +export function isMatchWinCompleted(match: DeepPartial): boolean { + return ( + match.opponent1?.result === "win" || + match.opponent2?.result === "win" || + match.opponent1?.result === "loss" || + match.opponent2?.result === "loss" + ); +} + +/** + * Checks if a match is completed because of at least one BYE. + * + * A match "BYE vs. TBD" isn't considered completed yet. + * + * @param match Partial match results. + */ +export function isMatchByeCompleted(match: DeepPartial): boolean { + return ( + (match.opponent1 === null && match.opponent2?.id !== null) || // BYE vs. someone + (match.opponent2 === null && match.opponent1?.id !== null) || // someone vs. BYE + (match.opponent1 === null && match.opponent2 === null) + ); // BYE vs. BYE +} + +/** + * Checks if a match's results can't be updated. + * + * @param match The match to check. + */ +export function isMatchUpdateLocked(match: MatchResults): boolean { + return ( + match.status === Status.Locked || + match.status === Status.Waiting || + match.status === Status.Archived + ); +} + +/** + * Checks if a match's participants can't be updated. + * + * @param match The match to check. + */ +export function isMatchParticipantLocked(match: MatchResults): boolean { + return match.status >= Status.Running; +} + +/** + * Indicates whether a match has at least one BYE or not. + * + * @param match Partial match results. + */ +export function hasBye(match: DeepPartial): boolean { + return match.opponent1 === null || match.opponent2 === null; +} + +/** + * Returns the status of a match based on the opponents of a match. + * + * @param opponents The opponents of a match. + */ +export function getMatchStatus(opponents: Duel): Status; + +/** + * Returns the status of a match based on the results of a match. + * + * @param match Partial match results. + */ +export function getMatchStatus(match: MatchResults): Status; + +/** + * Returns the status of a match based on information about it. + * + * @param arg The opponents or partial results of the match. + */ +export function getMatchStatus(arg: Duel | MatchResults): Status { + const match = Array.isArray(arg) + ? { + opponent1: arg[0], + opponent2: arg[1], + } + : arg; + + if (hasBye(match)) + // At least one BYE. + return Status.Locked; + + if (match.opponent1?.id === null && match.opponent2?.id === null) + // Two TBD opponents. + return Status.Locked; + + if (match.opponent1?.id === null || match.opponent2?.id === null) + // One TBD opponent. + return Status.Waiting; + + if (isMatchCompleted(match)) return Status.Completed; + + if (isMatchStarted(match)) return Status.Running; + + return Status.Ready; +} + +/** + * Updates a match results based on an input. + * + * @param stored A reference to what will be updated in the storage. + * @param match Input of the update. + * @param inRoundRobin Indicates whether the match is in a round-robin stage. + */ +export function setMatchResults( + stored: MatchResults, + match: DeepPartial, + inRoundRobin: boolean +): { + statusChanged: boolean; + resultChanged: boolean; +} { + if ( + !inRoundRobin && + (match.opponent1?.result === "draw" || match.opponent2?.result === "draw") + ) + throw Error("Having a draw is forbidden in an elimination tournament."); + + const completed = isMatchCompleted(match); + const currentlyCompleted = isMatchCompleted(stored); + + setExtraFields(stored, match); + handleOpponentsInversion(stored, match); + + const statusChanged = setScores(stored, match); + + if (completed && currentlyCompleted) { + // Ensure everything is good. + setCompleted(stored, match, inRoundRobin); + return { statusChanged: false, resultChanged: true }; + } + + if (completed && !currentlyCompleted) { + setCompleted(stored, match, inRoundRobin); + return { statusChanged: true, resultChanged: true }; + } + + if (!completed && currentlyCompleted) { + resetMatchResults(stored); + return { statusChanged: true, resultChanged: true }; + } + + return { statusChanged, resultChanged: false }; +} + +/** + * Resets the results of a match. (status, forfeit, result) + * + * @param stored A reference to what will be updated in the storage. + */ +export function resetMatchResults(stored: MatchResults): void { + if (stored.opponent1) { + stored.opponent1.forfeit = undefined; + stored.opponent1.result = undefined; + } + + if (stored.opponent2) { + stored.opponent2.forfeit = undefined; + stored.opponent2.result = undefined; + } + + stored.status = getMatchStatus(stored); +} + +/** + * Passes user-defined extra fields to the stored match. + * + * @param stored A reference to what will be updated in the storage. + * @param match Input of the update. + */ +export function setExtraFields( + stored: MatchResults, + match: DeepPartial +): void { + const partialAssign = ( + target: unknown, + update: unknown, + ignoredKeys: string[] + ): void => { + if (!target || !update) return; + + const retainedKeys = Object.keys(update).filter( + (key) => !ignoredKeys.includes(key) + ); + + retainedKeys.forEach((key) => { + (target as Record)[key] = ( + update as Record + )[key]; + }); + }; + + const ignoredKeys: Array = [ + "id", + "number", + "stage_id", + "group_id", + "round_id", + "status", + "opponent1", + "opponent2", + "child_count", + "parent_id", + ]; + + const ignoredOpponentKeys: Array = [ + "id", + "score", + "position", + "forfeit", + "result", + ]; + + partialAssign(stored, match, ignoredKeys); + partialAssign(stored.opponent1, match.opponent1, ignoredOpponentKeys); + partialAssign(stored.opponent2, match.opponent2, ignoredOpponentKeys); +} + +/** + * Gets the id of the opponent at the given side of the given match. + * + * @param match The match to get the opponent from. + * @param side The side where to get the opponent from. + */ +export function getOpponentId(match: MatchResults, side: Side): number | null { + const opponent = match[side]; + return opponent && opponent.id; +} + +/** + * Gets the origin position of a side of a match. + * + * @param match The match. + * @param side The side. + */ +export function getOriginPosition(match: Match, side: Side): number { + const matchNumber = match[side]?.position; + if (matchNumber === undefined) throw Error("Position is undefined."); + + return matchNumber; +} + +/** + * Returns every loser in a list of matches. + * + * @param participants The list of participants. + * @param matches A list of matches to get losers of. + */ +export function getLosers( + participants: Participant[], + matches: Match[] +): Participant[][] { + const losers: Participant[][] = []; + + let currentRound: number | null = null; + let roundIndex = -1; + + for (const match of matches) { + if (match.round_id !== currentRound) { + currentRound = match.round_id; + roundIndex++; + losers[roundIndex] = []; + } + + const loser = getLoser(match); + if (loser === null) continue; + + losers[roundIndex].push(findParticipant(participants, loser)); + } + + return losers; +} + +/** + * Makes final standings based on participants grouped by ranking. + * + * @param grouped A list of participants grouped by ranking. + */ +export function makeFinalStandings( + grouped: Participant[][] +): FinalStandingsItem[] { + const standings: FinalStandingsItem[] = []; + + let rank = 1; + + for (const group of grouped) { + for (const participant of group) { + standings.push({ + id: participant.id, + name: participant.name, + rank, + }); + } + rank++; + } + + return standings; +} + +/** + * Returns the decisive match of a Grand Final. + * + * @param type The type of Grand Final. + * @param matches The matches in the Grand Final. + */ +export function getGrandFinalDecisiveMatch( + type: GrandFinalType, + matches: Match[] +): Match { + if (type === "simple") return matches[0]; + + if (type === "double") { + const result = getMatchResult(matches[0]); + + if (result === "opponent2") return matches[1]; + + return matches[0]; + } + + throw Error("The Grand Final is disabled."); +} + +/** + * Finds a participant in a list. + * + * @param participants The list of participants. + * @param slot The slot of the participant to find. + */ +export function findParticipant( + participants: Participant[], + slot: ParticipantSlot +): Participant { + if (!slot) throw Error("Cannot find a BYE participant."); + const participant = participants.find( + (participant) => participant.id === slot?.id + ); + if (!participant) throw Error("Participant not found."); + return participant; +} + +/** + * Gets the side the winner of the current match will go to in the next match. + * + * @param matchNumber Number of the current match. + * @param roundNumber Number of the current round. + * @param roundCount Count of rounds. + * @param matchLocation Location of the current match. + */ +export function getNextSide( + matchNumber: number, + roundNumber: number, + roundCount: number, + matchLocation: GroupType +): Side { + // The nextSide comes from the same bracket. + if (matchLocation === "loser_bracket" && roundNumber % 2 === 1) + return "opponent2"; + + // The nextSide comes from the loser bracket to the final group. + if (matchLocation === "loser_bracket" && roundNumber === roundCount) + return "opponent2"; + + return getSide(matchNumber); +} + +/** + * Gets the side the winner of the current match in loser bracket will go in the next match. + * + * @param matchNumber Number of the match. + * @param nextMatch The next match. + * @param roundNumber Number of the current round. + */ +export function getNextSideLoserBracket( + matchNumber: number, + nextMatch: Match, + roundNumber: number +): Side { + // The nextSide comes from the WB. + if (roundNumber > 1) return "opponent1"; + + // The nextSide comes from the WB round 1. + if (nextMatch.opponent1?.position === matchNumber) return "opponent1"; + + return "opponent2"; +} + +export type SetNextOpponent = ( + nextMatch: Match, + nextSide: Side, + match?: Match, + currentSide?: Side +) => void; + +/** + * Sets an opponent in the next match he has to go. + * + * @param nextMatch A match which follows the current one. + * @param nextSide The side the opponent will be on in the next match. + * @param match The current match. + * @param currentSide The side the opponent is currently on. + */ +export function setNextOpponent( + nextMatch: Match, + nextSide: Side, + match?: Match, + currentSide?: Side +): void { + nextMatch[nextSide] = match![currentSide!] && { + // Keep BYE. + id: getOpponentId(match!, currentSide!), // This implementation of SetNextOpponent always has those arguments. + position: nextMatch[nextSide]?.position, // Keep position. + }; + + nextMatch.status = getMatchStatus(nextMatch); +} + +/** + * Resets an opponent in the match following the current one. + * + * @param nextMatch A match which follows the current one. + * @param nextSide The side the opponent will be on in the next match. + */ +export function resetNextOpponent(nextMatch: Match, nextSide: Side): void { + nextMatch[nextSide] = nextMatch[nextSide] && { + // Keep BYE. + id: null, + position: nextMatch[nextSide]?.position, // Keep position. + }; + + nextMatch.status = Status.Locked; +} + +/** + * Inverts opponents if requested by the input. + * + * @param stored A reference to what will be updated in the storage. + * @param match Input of the update. + */ +export function handleOpponentsInversion( + stored: MatchResults, + match: DeepPartial +): void { + const id1 = match.opponent1?.id; + const id2 = match.opponent2?.id; + + const storedId1 = stored.opponent1?.id; + const storedId2 = stored.opponent2?.id; + + if (Number.isInteger(id1) && id1 !== storedId1 && id1 !== storedId2) + throw Error("The given opponent1 ID does not exist in this match."); + + if (Number.isInteger(id2) && id2 !== storedId1 && id2 !== storedId2) + throw Error("The given opponent2 ID does not exist in this match."); + + if ( + (Number.isInteger(id1) && id1 === storedId2) || + (Number.isInteger(id2) && id2 === storedId1) + ) + invertOpponents(match); +} + +/** + * Inverts `opponent1` and `opponent2` in a match. + * + * @param match A match to update. + */ +export function invertOpponents(match: DeepPartial): void { + [match.opponent1, match.opponent2] = [match.opponent2, match.opponent1]; +} + +/** + * Updates the scores of a match. + * + * @param stored A reference to what will be updated in the storage. + * @param match Input of the update. + * @returns `true` if the status of the match changed, `false` otherwise. + */ +export function setScores( + stored: MatchResults, + match: DeepPartial +): boolean { + // Skip if no score update. + if ( + match.opponent1?.score === stored.opponent1?.score && + match.opponent2?.score === stored.opponent2?.score + ) + return false; + + const oldStatus = stored.status; + stored.status = Status.Running; + + if (match.opponent1 && stored.opponent1) + stored.opponent1.score = match.opponent1.score; + + if (match.opponent2 && stored.opponent2) + stored.opponent2.score = match.opponent2.score; + + return stored.status !== oldStatus; +} + +/** + * Completes a match and handles results and forfeits. + * + * @param stored A reference to what will be updated in the storage. + * @param match Input of the update. + * @param inRoundRobin Indicates whether the match is in a round-robin stage. + */ +export function setCompleted( + stored: MatchResults, + match: DeepPartial, + inRoundRobin: boolean +): void { + stored.status = Status.Completed; + + setResults(stored, match, "win", "loss", inRoundRobin); + setResults(stored, match, "loss", "win", inRoundRobin); + setResults(stored, match, "draw", "draw", inRoundRobin); + + if (stored.opponent1 && !stored.opponent2) stored.opponent1.result = "win"; // Win against opponent 2 BYE. + + if (!stored.opponent1 && stored.opponent2) stored.opponent2.result = "win"; // Win against opponent 1 BYE. + + setForfeits(stored, match); +} + +/** + * Enforces the symmetry between opponents. + * + * Sets an opponent's result to something, based on the result on the other opponent. + * + * @param stored A reference to what will be updated in the storage. + * @param match Input of the update. + * @param check A result to check in each opponent. + * @param change A result to set in each other opponent if `check` is correct. + * @param inRoundRobin Indicates whether the match is in a round-robin stage. + */ +export function setResults( + stored: MatchResults, + match: DeepPartial, + check: Result, + change: Result, + inRoundRobin: boolean +): void { + if (match.opponent1 && match.opponent2) { + if (match.opponent1.result === "win" && match.opponent2.result === "win") + throw Error("There are two winners."); + + if (match.opponent1.result === "loss" && match.opponent2.result === "loss") + throw Error("There are two losers."); + + if ( + !inRoundRobin && + match.opponent1.forfeit === true && + match.opponent2.forfeit === true + ) + throw Error("There are two forfeits."); + } + + if (match.opponent1?.result === check) { + if (stored.opponent1) stored.opponent1.result = check; + else stored.opponent1 = { id: null, result: check }; + + if (stored.opponent2) stored.opponent2.result = change; + else stored.opponent2 = { id: null, result: change }; + } + + if (match.opponent2?.result === check) { + if (stored.opponent2) stored.opponent2.result = check; + else stored.opponent2 = { id: null, result: check }; + + if (stored.opponent1) stored.opponent1.result = change; + else stored.opponent1 = { id: null, result: change }; + } +} + +/** + * Sets forfeits for each opponent (if needed). + * + * @param stored A reference to what will be updated in the storage. + * @param match Input of the update. + */ +export function setForfeits( + stored: MatchResults, + match: DeepPartial +): void { + if (match.opponent1?.forfeit === true && match.opponent2?.forfeit === true) { + if (stored.opponent1) stored.opponent1.forfeit = true; + if (stored.opponent2) stored.opponent2.forfeit = true; + + // Don't set any result (win/draw/loss) with a double forfeit + // so that it doesn't count any point in the ranking. + return; + } + + if (match.opponent1?.forfeit === true) { + if (stored.opponent1) stored.opponent1.forfeit = true; + + if (stored.opponent2) stored.opponent2.result = "win"; + else stored.opponent2 = { id: null, result: "win" }; + } + + if (match.opponent2?.forfeit === true) { + if (stored.opponent2) stored.opponent2.forfeit = true; + + if (stored.opponent1) stored.opponent1.result = "win"; + else stored.opponent1 = { id: null, result: "win" }; + } +} + +/** + * Indicates if a seeding is filled with participants' IDs. + * + * @param seeding The seeding. + */ +export function isSeedingWithIds(seeding: Seeding): boolean { + return seeding.some((value) => typeof value === "number"); +} + +/** + * Extracts participants from a seeding, without the BYEs. + * + * @param tournamentId ID of the tournament. + * @param seeding The seeding (no IDs). + */ +export function extractParticipantsFromSeeding( + tournamentId: number, + seeding: Seeding +): OmitId[] { + const withoutByes = seeding.filter( + (name): name is /* number */ string | CustomParticipant => name !== null + ); + + const participants = withoutByes.map>((item) => { + if (typeof item === "string") { + return { + tournament_id: tournamentId, + name: item, + }; + } + + return { + ...item, + tournament_id: tournamentId, + name: item.name, + }; + }); + + return participants; +} + +/** + * Returns participant slots mapped to the instances stored in the database thanks to their name. + * + * @param seeding The seeding. + * @param database The participants stored in the database. + * @param positions An optional list of positions (seeds) for a manual ordering. + */ +export function mapParticipantsNamesToDatabase( + seeding: Seeding, + database: Participant[], + positions?: number[] +): ParticipantSlot[] { + return mapParticipantsToDatabase("name", seeding, database, positions); +} + +/** + * Returns participant slots mapped to the instances stored in the database thanks to their id. + * + * @param seeding The seeding. + * @param database The participants stored in the database. + * @param positions An optional list of positions (seeds) for a manual ordering. + */ +export function mapParticipantsIdsToDatabase( + seeding: Seeding, + database: Participant[], + positions?: number[] +): ParticipantSlot[] { + return mapParticipantsToDatabase("id", seeding, database, positions); +} + +/** + * Returns participant slots mapped to the instances stored in the database thanks to a property of theirs. + * + * @param prop The property to search participants with. + * @param seeding The seeding. + * @param database The participants stored in the database. + * @param positions An optional list of positions (seeds) for a manual ordering. + */ +export function mapParticipantsToDatabase( + prop: "id" | "name", + seeding: Seeding, + database: Participant[], + positions?: number[] +): ParticipantSlot[] { + const slots = seeding.map((slot, i) => { + if (slot === null) return null; // BYE. + + const found = database.find((participant) => + typeof slot === "object" + ? participant[prop] === slot[prop] + : participant[prop] === slot + ); + + if (!found) throw Error(`Participant ${prop} not found in database.`); + + return { id: found.id, position: i + 1 }; + }); + + if (!positions) return slots; + + if (positions.length !== slots.length) + throw Error( + "Not enough seeds in at least one group of the manual ordering." + ); + + return positions.map((position) => slots[position - 1]); // Because `position` is `i + 1`. +} + +/** + * Converts a list of matches to a seeding. + * + * @param matches The input matches. + */ +export function convertMatchesToSeeding(matches: Match[]): ParticipantSlot[] { + const flattened = ([] as ParticipantSlot[]).concat( + ...matches.map((match) => [match.opponent1, match.opponent2]) + ); + return sortSeeding(flattened); +} + +/** + * Converts a list of slots to an input seeding. + * + * @param slots The slots to convert. + */ +export function convertSlotsToSeeding(slots: ParticipantSlot[]): Seeding { + return slots.map((slot) => { + if (slot === null || slot.id === null) return null; // BYE or TBD. + return slot.id; // Let's return the ID instead of the name to be sure we keep the same reference. + }); +} + +/** + * Sorts the seeding with the BYEs in the correct position. + * + * @param slots A list of slots to sort. + */ +export function sortSeeding(slots: ParticipantSlot[]): ParticipantSlot[] { + const withoutByes = slots.filter((v) => v !== null); + + // a and b are not null because of the filter. + // The slots are from seeding slots, thus they have a position. + withoutByes.sort((a, b) => a!.position! - b!.position!); + + if (withoutByes.length === slots.length) return withoutByes; + + // Same for v and position. + const placed = Object.fromEntries( + withoutByes.map((v) => [v!.position! - 1, v]) + ); + const sortedWithByes = Array.from( + { length: slots.length }, + (_, i) => placed[i] || null + ); + + return sortedWithByes; +} + +/** + * Returns only the non null elements. + * + * @param array The array to process. + */ +export function getNonNull(array: Nullable[]): T[] { + // Use a TS type guard to exclude null from the resulting type. + const nonNull = array.filter((element): element is T => element !== null); + return nonNull; +} + +/** + * Returns a list of objects which have unique values of a specific key. + * + * @param array The array to process. + * @param key The key to filter by. + */ +export function uniqueBy(array: T[], key: (obj: T) => unknown): T[] { + const seen = new Set(); + return array.filter((item) => { + const value = key(item); + if (!value) return true; + if (seen.has(value)) return false; + seen.add(value); + return true; + }); +} + +/** + * Makes the transition to a major round for duels of the previous round. The duel count is divided by 2. + * + * @param previousDuels The previous duels to transition from. + */ +export function transitionToMajor(previousDuels: Duel[]): Duel[] { + const currentDuelCount = previousDuels.length / 2; + const currentDuels: Duel[] = []; + + for (let duelIndex = 0; duelIndex < currentDuelCount; duelIndex++) { + const prevDuelId = duelIndex * 2; + currentDuels.push([ + byeWinner(previousDuels[prevDuelId]), + byeWinner(previousDuels[prevDuelId + 1]), + ]); + } + + return currentDuels; +} + +/** + * Makes the transition to a minor round for duels of the previous round. The duel count stays the same. + * + * @param previousDuels The previous duels to transition from. + * @param losers Losers from the previous major round. + * @param method The ordering method for the losers. + */ +export function transitionToMinor( + previousDuels: Duel[], + losers: ParticipantSlot[], + method?: SeedOrdering +): Duel[] { + const orderedLosers = method ? ordering[method](losers) : losers; + const currentDuelCount = previousDuels.length; + const currentDuels: Duel[] = []; + + for (let duelIndex = 0; duelIndex < currentDuelCount; duelIndex++) { + const prevDuelId = duelIndex; + currentDuels.push([ + orderedLosers[prevDuelId], + byeWinner(previousDuels[prevDuelId]), + ]); + } + + return currentDuels; +} + +/** + * Sets the parent match to a completed status if all its child games are completed. + * + * @param parent The partial parent match to update. + * @param childCount Child count of this parent match. + * @param inRoundRobin Indicates whether the parent match is in a round-robin stage. + */ +export function setParentMatchCompleted( + parent: Pick, + childCount: number, + inRoundRobin: boolean +): void { + if ( + parent.opponent1?.score === undefined || + parent.opponent2?.score === undefined + ) + throw Error("Either opponent1, opponent2 or their scores are falsy."); + + const minToWin = minScoreToWinBestOfX(childCount); + + if (parent.opponent1.score >= minToWin) { + parent.opponent1.result = "win"; + return; + } + + if (parent.opponent2.score >= minToWin) { + parent.opponent2.result = "win"; + return; + } + + if ( + parent.opponent1.score === parent.opponent2.score && + parent.opponent1.score + parent.opponent2.score > childCount - 1 + ) { + if (inRoundRobin) { + parent.opponent1.result = "draw"; + parent.opponent2.result = "draw"; + return; + } + + throw Error("Match games result in a tie for the parent match."); + } +} + +/** + * Returns a parent match results based on its child games scores. + * + * @param storedParent The parent match stored in the database. + * @param scores The scores of the match child games. + */ +export function getParentMatchResults( + storedParent: Match, + scores: Scores +): Pick { + return { + opponent1: { + id: storedParent.opponent1 && storedParent.opponent1.id, + score: scores.opponent1, + }, + opponent2: { + id: storedParent.opponent2 && storedParent.opponent2.id, + score: scores.opponent2, + }, + }; +} + +/** + * Gets the values which need to be updated in a match when it's updated on insertion. + * + * @param match The up to date match. + * @param existing The base match. + * @param enableByes Whether to use BYEs or TBDs for `null` values in an input seeding. + */ +export function getUpdatedMatchResults( + match: T, + existing: T, + enableByes: boolean +): T { + return { + ...existing, + ...match, + ...(enableByes + ? { + opponent1: + match.opponent1 === null + ? null + : { ...existing.opponent1, ...match.opponent1 }, + opponent2: + match.opponent2 === null + ? null + : { ...existing.opponent2, ...match.opponent2 }, + } + : { + opponent1: + match.opponent1 === null + ? { id: null } + : { ...existing.opponent1, ...match.opponent1 }, + opponent2: + match.opponent2 === null + ? { id: null } + : { ...existing.opponent2, ...match.opponent2 }, + }), + }; +} + +/** + * Calculates the score of a parent match based on its child games. + * + * @param games The child games to process. + */ +export function getChildGamesResults(games: MatchGame[]): Scores { + const scores = { + opponent1: 0, + opponent2: 0, + }; + + for (const game of games) { + const result = getMatchResult(game); + if (result === "opponent1") scores.opponent1++; + else if (result === "opponent2") scores.opponent2++; + } + + return scores; +} + +/** + * Gets the default list of seeds for a round's matches. + * + * @param inLoserBracket Whether the match is in the loser bracket. + * @param roundNumber The number of the current round. + * @param roundCountLB The count of rounds in loser bracket. + * @param matchCount The count of matches in the round. + */ +export function getSeeds( + inLoserBracket: boolean, + roundNumber: number, + roundCountLB: number, + matchCount: number +): number[] { + const seedCount = getSeedCount( + inLoserBracket, + roundNumber, + roundCountLB, + matchCount + ); + return Array.from(Array(seedCount), (_, i) => i + 1); +} + +/** + * Gets the number of seeds for a round's matches. + * + * @param inLoserBracket Whether the match is in the loser bracket. + * @param roundNumber The number of the current round. + * @param roundCountLB The count of rounds in loser bracket. + * @param matchCount The count of matches in the round. + */ +export function getSeedCount( + inLoserBracket: boolean, + roundNumber: number, + roundCountLB: number, + matchCount: number +): number { + ensureOrderingSupported(inLoserBracket, roundNumber, roundCountLB); + + return roundNumber === 1 + ? matchCount * 2 // Two per match for upper or lower bracket round 1. + : matchCount; // One per match for loser bracket minor rounds. +} + +/** + * Throws if the ordering is not supported on the given round number. + * + * @param inLoserBracket Whether the match is in the loser bracket. + * @param roundNumber The number of the round. + * @param roundCountLB The count of rounds in loser bracket. + */ +export function ensureOrderingSupported( + inLoserBracket: boolean, + roundNumber: number, + roundCountLB: number +): void { + if ( + inLoserBracket && + !isOrderingSupportedLoserBracket(roundNumber, roundCountLB) + ) + throw Error("This round does not support ordering."); + + if (!inLoserBracket && !isOrderingSupportedUpperBracket(roundNumber)) + throw Error("This round does not support ordering."); +} + +/** + * Indicates whether the ordering is supported in upper bracket, given the round number. + * + * @param roundNumber The number of the round. + */ +export function isOrderingSupportedUpperBracket(roundNumber: number): boolean { + return roundNumber === 1; +} + +/** + * Indicates whether the ordering is supported in loser bracket, given the round number. + * + * @param roundNumber The number of the round. + * @param roundCount The count of rounds. + */ +export function isOrderingSupportedLoserBracket( + roundNumber: number, + roundCount: number +): boolean { + return ( + roundNumber === 1 || (roundNumber % 2 === 0 && roundNumber < roundCount) + ); +} + +/** + * Returns the number of rounds an upper bracket has given the number of participants in the stage. + * + * @param participantCount The number of participants in the stage. + */ +export function getUpperBracketRoundCount(participantCount: number): number { + return Math.log2(participantCount); +} + +/** + * Returns the count of round pairs (major & minor) in a loser bracket. + * + * @param participantCount The number of participants in the stage. + */ +export function getRoundPairCount(participantCount: number): number { + return getUpperBracketRoundCount(participantCount) - 1; +} + +/** + * Determines whether a double elimination stage is really necessary. + * + * If the size is only two (less is impossible), then a lower bracket and a grand final are not necessary. + * + * @param participantCount The number of participants in the stage. + */ +export function isDoubleEliminationNecessary( + participantCount: number +): boolean { + return participantCount > 2; +} + +/** + * Returns the real (because of loser ordering) number of a match in a loser bracket. + * + * @param participantCount The number of participants in a stage. + * @param roundNumber Number of the round. + * @param matchNumber Number of the match. + * @param method The method used for the round. + */ +export function findLoserMatchNumber( + participantCount: number, + roundNumber: number, + matchNumber: number, + method?: SeedOrdering +): number { + const loserCount = getLoserRoundLoserCount(participantCount, roundNumber); + const losers = Array.from(Array(loserCount), (_, i) => i + 1); + const ordered = method ? ordering[method](losers) : losers; + const matchNumberLB = ordered.indexOf(matchNumber) + 1; + + // For LB round 1, the list of losers is spread over the matches 2 by 2. + if (roundNumber === 1) return Math.ceil(matchNumberLB / 2); + + return matchNumberLB; +} + +/** + * Returns the count of matches in a round of a loser bracket. + * + * @param participantCount The number of participants in a stage. + * @param roundNumber Number of the round. + */ +export function getLoserRoundMatchCount( + participantCount: number, + roundNumber: number +): number { + const roundPairIndex = Math.ceil(roundNumber / 2) - 1; + const roundPairCount = getRoundPairCount(participantCount); + const matchCount = Math.pow(2, roundPairCount - roundPairIndex - 1); + return matchCount; +} + +/** + * Returns the count of losers in a round of a loser bracket. + * + * @param participantCount The number of participants in a stage. + * @param roundNumber Number of the round. + */ +export function getLoserRoundLoserCount( + participantCount: number, + roundNumber: number +): number { + const matchCount = getLoserRoundMatchCount(participantCount, roundNumber); + + // Two per match for LB round 1 (losers coming from WB round 1). + if (roundNumber === 1) return matchCount * 2; + + return matchCount; // One per match for LB minor rounds. +} + +/** + * Returns the ordering method of a round of a loser bracket. + * + * @param seedOrdering The list of seed orderings. + * @param roundNumber Number of the round. + */ +export function getLoserOrdering( + seedOrdering: SeedOrdering[], + roundNumber: number +): SeedOrdering | undefined { + const orderingIndex = 1 + Math.floor(roundNumber / 2); + return seedOrdering[orderingIndex]; +} + +/** + * Returns the number of rounds a lower bracket has given the number of participants in a double elimination stage. + * + * @param participantCount The number of participants in the stage. + */ +export function getLowerBracketRoundCount(participantCount: number): number { + const roundPairCount = getRoundPairCount(participantCount); + return roundPairCount * 2; +} + +/** + * Returns the match number of the corresponding match in the next round by dividing by two. + * + * @param matchNumber The current match number. + */ +export function getDiagonalMatchNumber(matchNumber: number): number { + return Math.ceil(matchNumber / 2); +} + +/** + * Returns the nearest power of two **greater than** or equal to the given number. + * + * @param input The input number. + */ +export function getNearestPowerOfTwo(input: number): number { + return Math.pow(2, Math.ceil(Math.log2(input))); +} + +/** + * Returns the minimum score a participant must have to win a Best Of X series match. + * + * @param x The count of child games in the series. + */ +export function minScoreToWinBestOfX(x: number): number { + return (x + 1) / 2; +} + +/** + * Checks if a stage is a round-robin stage. + * + * @param stage The stage to check. + */ +export function isRoundRobin(stage: Stage): boolean { + return stage.type === "round_robin"; +} + +/** + * Throws if a stage is round-robin. + * + * @param stage The stage to check. + */ +export function ensureNotRoundRobin(stage: Stage): void { + const inRoundRobin = isRoundRobin(stage); + if (inRoundRobin) + throw Error("Impossible to update ordering in a round-robin stage."); +} + +/** + * Checks if a round is completed based on its matches. + * + * @param roundMatches Matches of the round. + */ +export function isRoundCompleted(roundMatches: Match[]): boolean { + return roundMatches.every((match) => match.status >= Status.Completed); +} + +/** + * Checks if a group is a winner bracket. + * + * It's not always the opposite of `inLoserBracket()`: it could be the only bracket of a single elimination stage. + * + * @param stageType Type of the stage. + * @param groupNumber Number of the group. + */ +export function isWinnerBracket( + stageType: StageType, + groupNumber: number +): boolean { + return stageType === "double_elimination" && groupNumber === 1; +} + +/** + * Checks if a group is a loser bracket. + * + * @param stageType Type of the stage. + * @param groupNumber Number of the group. + */ +export function isLoserBracket( + stageType: StageType, + groupNumber: number +): boolean { + return stageType === "double_elimination" && groupNumber === 2; +} + +/** + * Checks if a group is a final group (consolation final or grand final). + * + * @param stageType Type of the stage. + * @param groupNumber Number of the group. + */ +export function isFinalGroup( + stageType: StageType, + groupNumber: number +): boolean { + return ( + (stageType === "single_elimination" && groupNumber === 2) || + (stageType === "double_elimination" && groupNumber === 3) + ); +} + +/** + * Returns the type of group the match is located into. + * + * @param stageType Type of the stage. + * @param groupNumber Number of the group. + */ +export function getMatchLocation( + stageType: StageType, + groupNumber: number +): GroupType { + if (isWinnerBracket(stageType, groupNumber)) return "winner_bracket"; + + if (isLoserBracket(stageType, groupNumber)) return "loser_bracket"; + + if (isFinalGroup(stageType, groupNumber)) return "final_group"; + + return "single_bracket"; +} + +/** + * Returns the fraction of final for the current round (e.g. `1/2` for semi finals or `1/4` for quarter finals). + * + * @param roundNumber Number of the current round. + * @param roundCount Count of rounds. + */ +export function getFractionOfFinal( + roundNumber: number, + roundCount: number +): number { + if (roundNumber > roundCount) + throw Error( + `There are more rounds than possible. ${JSON.stringify({ + roundNumber, + roundCount, + })}` + ); + + const denominator = Math.pow(2, roundCount - roundNumber); + return 1 / denominator; +} diff --git a/app/modules/brackets-manager/index.ts b/app/modules/brackets-manager/index.ts new file mode 100644 index 000000000..92b648a5d --- /dev/null +++ b/app/modules/brackets-manager/index.ts @@ -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, +}; diff --git a/app/modules/brackets-manager/manager.ts b/app/modules/brackets-manager/manager.ts new file mode 100644 index 000000000..34278260c --- /dev/null +++ b/app/modules/brackets-manager/manager.ts @@ -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 = ( + table: T, + filter: Partial + ): DataTypes[T] | null => { + const results = this.storage.select(table, filter); + if (!results || results.length === 0) return null; + return results[0]; + }; + + storage.selectLast = ( + table: T, + filter: Partial + ): DataTypes[T] | null => { + const results = this.storage.select(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, + }; + } +} diff --git a/app/modules/brackets-manager/ordering.ts b/app/modules/brackets-manager/ordering.ts new file mode 100644 index 000000000..fb430b005 --- /dev/null +++ b/app/modules/brackets-manager/ordering.ts @@ -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: (array: T[]) => [...array], + reverse: (array: T[]) => [...array].reverse(), + half_shift: (array: T[]) => [ + ...array.slice(array.length / 2), + ...array.slice(0, array.length / 2), + ], + reverse_half_shift: (array: T[]) => [ + ...array.slice(0, array.length / 2).reverse(), + ...array.slice(array.length / 2).reverse(), + ], + pair_flip: (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: (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": (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": (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", + ], +}; diff --git a/app/modules/brackets-manager/reset.ts b/app/modules/brackets-manager/reset.ts new file mode 100644 index 000000000..2237b6618 --- /dev/null +++ b/app/modules/brackets-manager/reset.ts @@ -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); + } +} diff --git a/app/modules/brackets-manager/types.ts b/app/modules/brackets-manager/types.ts new file mode 100644 index 000000000..4e3bc751f --- /dev/null +++ b/app/modules/brackets-manager/types.ts @@ -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, + (array: T[], ...args: number[]) => T[] +>; + +/** + * Omits the `id` property of a type. + */ +export type OmitId = Omit; + +/** + * Defines a T which can be null. + */ +export type Nullable = T | null; + +/** + * An object which maps an ID to another ID. + */ +export type IdMapping = Record; + +/** + * 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 { + even: T[]; + odd: T[]; +} + +/** + * Makes an object type deeply partial. + */ +export type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; + +/** + * Converts all value types to array types. + */ +type ValueToArray = { + [K in keyof T]: Array; +}; + +/** + * 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; + +/** + * 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(table: T, value: OmitId): number; + + /** + * Inserts multiple values in the database. + * + * @param table Where to insert. + * @param values What to insert. + */ + insert(table: T, values: OmitId[]): boolean; + + /** + * Gets all data from a table in the database. + * + * @param table Where to get from. + */ + select(table: T): Array | null; + + /** + * Gets specific data from a table in the database. + * + * @param table Where to get from. + * @param id What to get. + */ + select(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( + table: T, + filter: Partial + ): Array | null; + + /** + * Updates data in a table. + * + * @param table Where to update. + * @param id What to update. + * @param value How to update. + */ + update(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( + table: T, + filter: Partial, + value: Partial + ): boolean; + + /** + * Empties a table completely. + * + * @param table Where to delete everything. + */ + delete(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(table: T, filter: Partial): boolean; +} + +export interface Storage extends CrudInterface { + selectFirst( + table: T, + filter: Partial + ): DataTypes[T] | null; + selectLast( + table: T, + filter: Partial + ): DataTypes[T] | null; +} diff --git a/app/modules/brackets-manager/update.ts b/app/modules/brackets-manager/update.ts new file mode 100644 index 000000000..0d7e0a3c3 --- /dev/null +++ b/app/modules/brackets-manager/update.ts @@ -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(match: DeepPartial): 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( + game: DeepPartial + ): 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."); + } +} diff --git a/app/modules/brackets-memory-db/README.md b/app/modules/brackets-memory-db/README.md new file mode 100644 index 000000000..186e43067 --- /dev/null +++ b/app/modules/brackets-memory-db/README.md @@ -0,0 +1 @@ +Taken from https://github.com/Drarig29/brackets-storage diff --git a/app/modules/brackets-memory-db/index.ts b/app/modules/brackets-memory-db/index.ts new file mode 100644 index 000000000..adac7fc90 --- /dev/null +++ b/app/modules/brackets-memory-db/index.ts @@ -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(table: Table, value: OmitId): number; + /** + * Inserts multiple values in the database. + * + * @param table Where to insert. + * @param values What to insert. + */ + insert(table: Table, values: OmitId[]): boolean; + + /** + * Implementation of insert + * + * @param table Where to insert. + * @param values What to insert. + */ + insert(table: Table, values: OmitId | OmitId[]): 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(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(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(table: Table, filter: Partial): T[] | null; + + /** + * @param table Where to get from. + * @param arg Arg. + */ + select(table: Table, arg?: number | Partial): 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(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(table: Table, filter: Partial, value: Partial): boolean; + + /** + * Updates data in a table. + * + * @param table Where to update. + * @param arg + * @param value How to update. + */ + update( + table: Table, + arg: number | Partial, + value?: Partial + ): 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(table: Table, filter: Partial): boolean; + + /** + * Delete data in a table, based on a filter. + * + * @param table Where to delete in. + * @param filter An object to filter data. + */ + delete(table: Table, filter?: Partial): 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; + } +} diff --git a/app/modules/tournament-map-list-generator/constants.ts b/app/modules/tournament-map-list-generator/constants.ts index dbc70a470..41c9ec11d 100644 --- a/app/modules/tournament-map-list-generator/constants.ts +++ b/app/modules/tournament-map-list-generator/constants.ts @@ -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; diff --git a/app/modules/tournament-map-list-generator/generation.test.ts b/app/modules/tournament-map-list-generator/generation.test.ts index 9e0df3bf6..1db61da53 100644 --- a/app/modules/tournament-map-list-generator/generation.test.ts +++ b/app/modules/tournament-map-list-generator/generation.test.ts @@ -40,8 +40,7 @@ const tiebreakerPicks = new MapPool([ const generateMaps = ({ bestOf = 5, - bracketType = "SE", - roundNumber = 3, + seed = "test", teams = [ { id: 1, @@ -57,8 +56,7 @@ const generateMaps = ({ }: Partial = {}) => { return createTournamentMapList({ bestOf, - bracketType, - roundNumber, + seed, teams, tiebreakerMaps, modesIncluded, @@ -343,7 +341,7 @@ TournamentMapListGenerator("No map picked by same team twice in row", () => { maps: team2Picks, }, ], - roundNumber: i, + seed: String(i), }); for (let j = 0; j < mapList.length - 1; j++) { @@ -353,6 +351,118 @@ TournamentMapListGenerator("No map picked by same team twice in row", () => { } }); +// TODO: figure out how to handle this +// checks for case were there is complete overlap in one mode but not others +// which means with forced tiebreaker the map list would become unbalanced +// TournamentMapListGenerator.only( +// "Handles impossible duplication situation by using BOTH as tiebreaker", +// () => { +// const maps = generateMaps({ +// teams: [ +// { +// id: 11, +// maps: new MapPool([ +// // dupe +// { +// stageId: 11, +// mode: "RM", +// }, +// { +// stageId: 11, +// mode: "TC", +// }, +// { +// stageId: 3, +// mode: "SZ", +// }, +// // dupe +// { +// stageId: 1, +// mode: "RM", +// }, +// { +// stageId: 4, +// mode: "SZ", +// }, +// { +// stageId: 10, +// mode: "CB", +// }, +// { +// stageId: 3, +// mode: "TC", +// }, +// { +// stageId: 2, +// mode: "CB", +// }, +// ]), +// }, +// { +// id: 4, +// maps: new MapPool([ +// { +// stageId: 2, +// mode: "SZ", +// }, +// { +// stageId: 10, +// mode: "TC", +// }, +// { +// stageId: 8, +// mode: "SZ", +// }, +// { +// stageId: 11, +// mode: "RM", +// }, +// { +// stageId: 6, +// mode: "TC", +// }, +// { +// stageId: 1, +// mode: "RM", +// }, +// { +// stageId: 11, +// mode: "CB", +// }, +// { +// stageId: 6, +// mode: "CB", +// }, +// ]), +// }, +// ], +// seed: String(1), +// bestOf: 5, +// modesIncluded: ["SZ", "TC", "RM", "CB"], +// tiebreakerMaps: new MapPool([ +// { +// stageId: 1, +// mode: "SZ", +// }, +// { +// stageId: 2, +// mode: "TC", +// }, +// { +// stageId: 3, +// mode: "RM", +// }, +// { +// stageId: 4, +// mode: "CB", +// }, +// ]), +// }); + +// assert.equal(maps[maps.length - 1].source, "BOTH"); +// } +// ); + const team1SZPicks = new MapPool([ { mode: "SZ", stageId: 4 }, { mode: "SZ", stageId: 5 }, @@ -463,7 +573,7 @@ TournamentMapListGeneratorOneMode( }, ], modesIncluded: ["SZ"], - roundNumber: i, + seed: String(i), tiebreakerMaps: new MapPool([]), }); diff --git a/app/modules/tournament-map-list-generator/index.ts b/app/modules/tournament-map-list-generator/index.ts index 8888acec5..31e0e1529 100644 --- a/app/modules/tournament-map-list-generator/index.ts +++ b/app/modules/tournament-map-list-generator/index.ts @@ -5,3 +5,5 @@ export type { TournamentMaplistSource, TournamentMapListMap, } from "./types"; +export { sourceTypes } from "./constants"; +export { seededRandom } from "./utils"; diff --git a/app/modules/tournament-map-list-generator/tournament-map-list.ts b/app/modules/tournament-map-list-generator/tournament-map-list.ts index 3926fd8b3..76482ade4 100644 --- a/app/modules/tournament-map-list-generator/tournament-map-list.ts +++ b/app/modules/tournament-map-list-generator/tournament-map-list.ts @@ -15,7 +15,13 @@ const OPTIMAL_MAPLIST_SCORE = 0; export function createTournamentMapList( input: TournamentMaplistInput ): Array { - const { shuffle } = seededRandom(`${input.bracketType}-${input.roundNumber}`); + invariant( + input.modesIncluded.length === 1 || + input.tiebreakerMaps.stageModePairs.length > 0, + "Must include tiebreaker maps if there are multiple modes" + ); + + const { shuffle } = seededRandom(input.seed); const stages = shuffle(resolveCommonStages()); const mapList: Array = []; const bestMapList: { maps?: Array; score: number } = { diff --git a/app/modules/tournament-map-list-generator/types.ts b/app/modules/tournament-map-list-generator/types.ts index 4a965af48..507500031 100644 --- a/app/modules/tournament-map-list-generator/types.ts +++ b/app/modules/tournament-map-list-generator/types.ts @@ -1,5 +1,6 @@ import type { ModeShort, ModeWithStage } from "../in-game-lists"; import type { MapPool } from "../map-pool-serializer"; +import type { sourceTypes } from "./constants"; export type BracketType = | "GROUPS" @@ -10,8 +11,7 @@ export type BracketType = export interface TournamentMaplistInput { bestOf: 3 | 5 | 7; - roundNumber: number; - bracketType: BracketType; + seed: string; teams: [ { id: number; @@ -26,11 +26,7 @@ export interface TournamentMaplistInput { modesIncluded: ModeShort[]; } -export type TournamentMaplistSource = - | number - | "DEFAULT" - | "TIEBREAKER" - | "BOTH"; +export type TournamentMaplistSource = number | (typeof sourceTypes)[number]; export type TournamentMapListMap = ModeWithStage & { source: TournamentMaplistSource; diff --git a/app/permissions.ts b/app/permissions.ts index 80eb23cb6..f32a59ca7 100644 --- a/app/permissions.ts +++ b/app/permissions.ts @@ -11,6 +11,7 @@ import { ADMIN_ID, LOHI_TOKEN_HEADER_NAME } from "./constants"; import invariant from "tiny-invariant"; import type { ManagersByBadgeId } from "./db/models/badges/queries.server"; import { databaseTimestampToDate } from "./utils/dates"; +import type { FindMatchById } from "./features/tournament-bracket/queries/findMatchById.server"; // TODO: 1) move "root checkers" to one file and utils to one file 2) make utils const for more terseness @@ -312,17 +313,36 @@ export function canEnableTOTools(user?: IsAdminUser) { return isAdmin(user); } -interface CanAdminCalendarTOTools { +interface CanAdminTournament { user?: Pick; event: Pick; } -export function canAdminCalendarTOTools({ - user, - event, -}: CanAdminCalendarTOTools) { +export function canAdminTournament({ user, event }: CanAdminTournament) { return adminOverride(user)(user?.id === event.authorId); } +export function canReportTournamentScore({ + match, + user, + ownedTeamId, + event, +}: { + match: NonNullable; + user?: Pick; + ownedTeamId?: number; + event: CanAdminTournament["event"]; +}) { + const matchIsOver = + match.opponentOne?.result === "win" || match.opponentTwo?.result === "win"; + + return ( + !matchIsOver && + ((match.opponentOne?.id ?? -1) === ownedTeamId || + (match.opponentTwo?.id ?? -1) === ownedTeamId || + canAdminTournament({ user, event })) + ); +} + export function canAddCustomizedColorsToUserProfile( user?: Pick ) { diff --git a/app/root.tsx b/app/root.tsx index 1a4f996dd..4084b5a8a 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -164,6 +164,11 @@ function Document({ + {/* TODO: preferably don't load this for every route */} + diff --git a/app/routes/badges/$id.tsx b/app/routes/badges/$id.tsx index 8b034b019..4cd40dda3 100644 --- a/app/routes/badges/$id.tsx +++ b/app/routes/badges/$id.tsx @@ -43,7 +43,7 @@ export const loader: LoaderFunction = ({ params }) => { export default function BadgeDetailsPage() { const user = useUser(); const [, parentRoute] = useMatches(); - const { badges } = parentRoute!.data as BadgesLoaderData; + const { badges } = parentRoute.data as BadgesLoaderData; const params = useParams(); const data = useLoaderData(); const { t } = useTranslation("badges"); diff --git a/app/routes/calendar/$id/index.tsx b/app/routes/calendar/$id/index.tsx index ca279b5f5..940d47627 100644 --- a/app/routes/calendar/$id/index.tsx +++ b/app/routes/calendar/$id/index.tsx @@ -49,6 +49,7 @@ import { readonlyMapsPage, resolveBaseUrl, userPage, + tournamentPage, } from "~/utils/urls"; import { actualNumber, id } from "~/utils/zod"; import { Tags } from "../components/Tags"; @@ -65,8 +66,7 @@ export const action: ActionFunction = async ({ params, request }) => { user, event, startTime: databaseTimestampToDate(event.startTimes[0]!), - }), - 403 + }) ); db.calendarEvents.deleteById(event.eventId); @@ -121,6 +121,10 @@ export const loader = async ({ params, request }: LoaderArgs) => { .parse(params); const event = notFoundIfFalsy(db.calendarEvents.findById(parsedParams.id)); + if (event.tournamentId) { + throw redirect(tournamentPage(event.tournamentId)); + } + return json({ event, badgePrizes: db.calendarEvents.findBadgesByEventId(parsedParams.id), diff --git a/app/routes/calendar/$id/report-winners.tsx b/app/routes/calendar/$id/report-winners.tsx index 20d8f1289..2e904e94e 100644 --- a/app/routes/calendar/$id/report-winners.tsx +++ b/app/routes/calendar/$id/report-winners.tsx @@ -117,6 +117,7 @@ export const action: ActionFunction = async ({ request, params }) => { event, startTimes: event.startTimes, }), + "Unauthorized", 401 ); @@ -150,8 +151,7 @@ export const loader = async ({ request, params }: LoaderArgs) => { user, event, startTimes: event.startTimes, - }), - 401 + }) ); return json({ diff --git a/app/routes/calendar/index.tsx b/app/routes/calendar/index.tsx index 99b73da41..24d8761df 100644 --- a/app/routes/calendar/index.tsx +++ b/app/routes/calendar/index.tsx @@ -35,6 +35,7 @@ import { CALENDAR_PAGE, navIconUrl, resolveBaseUrl, + tournamentPage, } from "~/utils/urls"; import { actualNumber } from "~/utils/zod"; import { Tags } from "./components/Tags"; @@ -371,11 +372,19 @@ function EventsList({ })}
    - From {discordFullName(calendarEvent)} + {t("from", { + author: discordFullName(calendarEvent), + })}
    - +

    {calendarEvent.name}{" "} {calendarEvent.nthAppearance > 1 ? ( @@ -404,14 +413,16 @@ function EventsList({ Discord ) : null} - - {resolveBaseUrl(calendarEvent.bracketUrl)} - + {!calendarEvent.tournamentId ? ( + + {resolveBaseUrl(calendarEvent.bracketUrl)} + + ) : null}

    ); diff --git a/app/routes/calendar/new.tsx b/app/routes/calendar/new.tsx index b5e66aea9..fbec99943 100644 --- a/app/routes/calendar/new.tsx +++ b/app/routes/calendar/new.tsx @@ -28,7 +28,11 @@ import { SubmitButton } from "~/components/SubmitButton"; import { Toggle } from "~/components/Toggle"; import { CALENDAR_EVENT } from "~/constants"; import { db } from "~/db"; -import type { Badge as BadgeType, CalendarEventTag } from "~/db/types"; +import type { + Badge as BadgeType, + CalendarEventTag, + Tournament, +} from "~/db/types"; import { useIsMounted } from "~/hooks/useIsMounted"; import { useTranslation } from "~/hooks/useTranslation"; import { useUser } from "~/modules/auth"; @@ -159,8 +163,6 @@ export const action: ActionFunction = async ({ request }) => { rankedModesShort.find((mode) => mode === data.toToolsMode) ?? null, }; - // TODO: messing with these and "one mode selection" can cause problems when teams - // have already chosen maps for their pools const deserializedMaps = (() => { if (!data.pool) return; @@ -171,7 +173,11 @@ export const action: ActionFunction = async ({ request }) => { const eventToEdit = badRequestIfFalsy( db.calendarEvents.findById(data.eventToEditId) ); - validate(canEditCalendarEvent({ user, event: eventToEdit }), 401); + validate( + canEditCalendarEvent({ user, event: eventToEdit }), + "Not authorized", + 401 + ); db.calendarEvents.update({ eventId: data.eventToEditId, @@ -184,6 +190,10 @@ export const action: ActionFunction = async ({ request }) => { const createdEventId = db.calendarEvents.create({ authorId: user.id, mapPoolMaps: deserializedMaps, + createTournament: data.toToolsEnabled, + mapPickingStyle: data.toToolsMode + ? `AUTO_${data.toToolsMode}` + : "AUTO_ALL", ...commonArgs, }); @@ -216,8 +226,10 @@ export const loader = async ({ request }: LoaderArgs) => { eventToEdit: canEditEvent ? { ...eventToEdit, - // "BADGE" tag is special and can't be edited like other tags - tags: eventToEdit.tags.filter((tag) => tag !== "BADGE"), + // "BADGE" and "FULL_TOURNAMENT" tags are special and can't be edited like other tags + tags: eventToEdit.tags.filter( + (tag) => tag !== "BADGE" && tag !== "FULL_TOURNAMENT" + ), badges: db.calendarEvents.findBadgesByEventId(eventId), mapPool: db.calendarEvents.findMapPoolByEventId(eventId), tieBreakerMapPool: @@ -249,7 +261,7 @@ export default function CalendarNewEventPage() { - + {!eventToEdit ? : null} {t("actions.submit")} @@ -436,6 +448,7 @@ function DatesInput() { ); } +// TODO: when full tournament this doesn't really make sense function BracketUrlInput() { const { t } = useTranslation("calendar"); const { eventToEdit } = useLoaderData(); @@ -482,7 +495,7 @@ function TagsAdder() { const id = React.useId(); const tagsForSelect = CALENDAR_EVENT.TAGS.filter( - (tag) => !tags.includes(tag) && tag !== "BADGE" + (tag) => !tags.includes(tag) && tag !== "BADGE" && tag !== "FULL_TOURNAMENT" ); return ( @@ -591,14 +604,26 @@ function BadgesAdder() { ); } +const mapPickingStyleToShort: Record< + Tournament["mapPickingStyle"], + "ALL" | RankedModeShort +> = { + AUTO_ALL: "ALL", + AUTO_SZ: "SZ", + AUTO_TC: "TC", + AUTO_RM: "RM", + AUTO_CB: "CB", +}; function TOToolsAndMapPool() { const user = useUser(); const { eventToEdit } = useLoaderData(); const [checked, setChecked] = React.useState( - Boolean(eventToEdit?.toToolsEnabled) + Boolean(eventToEdit?.tournamentId) ); const [mode, setMode] = React.useState<"ALL" | RankedModeShort>( - eventToEdit?.toToolsMode ?? "ALL" + eventToEdit?.mapPickingStyle + ? mapPickingStyleToShort[eventToEdit.mapPickingStyle] + : "ALL" ); return ( diff --git a/app/routes/calendar/tags.json b/app/routes/calendar/tags.json index a4eaf5a8f..4721e5aba 100644 --- a/app/routes/calendar/tags.json +++ b/app/routes/calendar/tags.json @@ -43,5 +43,8 @@ }, "CARDS": { "color": "#E4D00A" + }, + "FULL_TOURNAMENT": { + "color": "#FFC0CB" } } diff --git a/app/routes/patrons.tsx b/app/routes/patrons.tsx index b30afb884..3a5184cdd 100644 --- a/app/routes/patrons.tsx +++ b/app/routes/patrons.tsx @@ -18,7 +18,7 @@ export const action: ActionFunction = async ({ request }) => { }; export const loader = ({ request }: LoaderArgs) => { - validate(canAccessLohiEndpoint(request), 403); + validate(canAccessLohiEndpoint(request), "Invalid token", 403); return db.users.findAllPatrons(); }; diff --git a/app/routes/seed.tsx b/app/routes/seed.tsx index 27f4c8cea..5351d327c 100644 --- a/app/routes/seed.tsx +++ b/app/routes/seed.tsx @@ -1,12 +1,27 @@ import type { ActionFunction } from "@remix-run/node"; +import { z } from "zod"; import { seed } from "~/db/seed"; +import { parseRequestFormData } from "~/utils/remix"; -export const action: ActionFunction = () => { +const seedSchema = z.object({ + variation: z.enum(["NO_TOURNAMENT_TEAMS", "DEFAULT"]).nullish(), +}); + +export type SeedVariation = NonNullable< + z.infer["variation"] +>; + +export const action: ActionFunction = async ({ request }) => { if (process.env.NODE_ENV === "production") { throw new Response(null, { status: 400 }); } - seed(); + const { variation } = await parseRequestFormData({ + request, + schema: seedSchema, + }); + + seed(variation); return null; }; diff --git a/app/styles/common.css b/app/styles/common.css index 971dd8233..294f597d2 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -1051,3 +1051,27 @@ dialog::backdrop { width: 100%; height: 100%; } + +.divider { + display: flex; + width: 100%; + align-items: center; + color: var(--theme); + font-size: var(--fonts-lg); + text-align: center; +} + +.divider::before, +.divider::after { + flex: 1; + border-bottom: 2px solid var(--theme-transparent); + content: ""; +} + +.divider:not(:empty)::before { + margin-right: 0.25em; +} + +.divider:not(:empty)::after { + margin-left: 0.25em; +} diff --git a/app/styles/layout.css b/app/styles/layout.css index c8dd6c876..228ab9353 100644 --- a/app/styles/layout.css +++ b/app/styles/layout.css @@ -27,13 +27,11 @@ overflow: initial; } -/** use css var */ .layout__breadcrumb-separator { font-size: 20px; } .layout__header { - /** xxx: remove */ --item-size: 1.9rem; position: fixed; diff --git a/app/styles/utils.css b/app/styles/utils.css index dde3a0bb8..af5f73d7d 100644 --- a/app/styles/utils.css +++ b/app/styles/utils.css @@ -46,6 +46,10 @@ color: var(--theme); } +.text-theme-secondary { + color: var(--theme-secondary); +} + .fill-success { fill: var(--theme-success); } @@ -54,6 +58,10 @@ fill: var(--theme-warning); } +.fill-error { + fill: var(--theme-error); +} + .bg-transparent-important { background-color: transparent !important; } @@ -154,6 +162,10 @@ margin-inline-start: auto; } +.ml-2 { + margin-inline-start: var(--s-2); +} + .mr-auto { margin-inline-end: auto; } @@ -254,6 +266,10 @@ display: inherit; } +.cursor-pointer { + cursor: pointer; +} + @media screen and (min-width: 480px) { .mobile-hidden { display: inherit; diff --git a/app/styles/vars.css b/app/styles/vars.css index ce20a4037..86986dc64 100644 --- a/app/styles/vars.css +++ b/app/styles/vars.css @@ -21,6 +21,7 @@ html { --divider: #f5a2c8; --theme-error: rgb(199 13 6); --theme-error-transparent: rgba(199 13 6 / 55%); + --theme-error-semi-transparent: rgba(199 13 6 / 70%); --theme-warning: #c9c900; --theme-warning-transparent: #c9c90052; --theme-success: #00a514; @@ -110,6 +111,7 @@ html.dark { --divider: #ffbedb2f; --theme-error: rgb(219 70 65); --theme-error-transparent: rgba(219 70 65 / 55%); + --theme-error-semi-transparent: rgba(199 13 6 / 70%); --theme-warning: #f5f587; --theme-success: #a3ffae; --theme-success-transparent: #a3ffae52; diff --git a/app/utils/playwright.ts b/app/utils/playwright.ts index c449ae689..59287dde0 100644 --- a/app/utils/playwright.ts +++ b/app/utils/playwright.ts @@ -1,4 +1,5 @@ import { expect, type Locator, type Page } from "@playwright/test"; +import type { SeedVariation } from "~/routes/seed"; export async function selectWeapon({ page, @@ -12,6 +13,23 @@ export async function selectWeapon({ return selectComboboxValue({ page, value: name, inputName }); } +export async function selectUser({ + page, + userName, + labelName, +}: { + page: Page; + userName: string; + labelName: string; +}) { + const combobox = page.getByLabel(labelName); + await expect(combobox).not.toBeDisabled(); + + await combobox.clear(); + await combobox.type(userName); + await page.keyboard.press("Enter"); +} + export async function selectComboboxValue({ page, value, @@ -38,16 +56,22 @@ export async function navigate({ page, url }: { page: Page; url: string }) { await expect(page.getByTestId("hydrated")).toHaveCount(1); } -export function seed(page: Page) { - return page.request.post("/seed"); +export function seed(page: Page, variation?: SeedVariation) { + return page.request.post("/seed", { + form: { variation: variation ?? "DEFAULT" }, + }); } export function impersonate(page: Page, userId = 1) { return page.request.post(`/auth/impersonate?id=${userId}`); } -export function submit(page: Page) { - return page.getByTestId("submit-button").click(); +export async function submit(page: Page) { + const responsePromise = page.waitForResponse( + (res) => res.request().method() === "POST" + ); + await page.getByTestId("submit-button").click(); + await responsePromise; } export function isNotVisible(locator: Locator) { @@ -57,3 +81,10 @@ export function isNotVisible(locator: Locator) { export function modalClickConfirmButton(page: Page) { return page.getByTestId("confirm-button").click(); } + +export async function fetchSendouInk(url: string) { + const res = await fetch(`http://localhost:5800${url}`); + if (!res.ok) throw new Error("Response not successful"); + + return res.json() as T; +} diff --git a/app/utils/remix.ts b/app/utils/remix.ts index b35e7a9e3..099c77a5e 100644 --- a/app/utils/remix.ts +++ b/app/utils/remix.ts @@ -37,11 +37,7 @@ export async function parseRequestFormData({ } catch (e) { if (e instanceof z.ZodError) { console.error(e); - let errorMessage = "Request had following issues: "; - for (const issue of e.issues) { - errorMessage += `${issue.message} (path: ${issue.path.join(",")});`; - } - throw new Response(errorMessage, { status: 400 }); + throw new Response(JSON.stringify(e), { status: 400 }); } throw e; @@ -96,12 +92,17 @@ function formDataToObject(formData: FormData) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- same format as TS docs: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#assertion-functions export function validate( condition: any, - status = 400, - body?: string + message?: string, + status = 400 ): asserts condition { if (condition) return; - throw new Response(body, { status }); + throw new Response( + message ? JSON.stringify({ validationError: message }) : undefined, + { + status, + } + ); } export type Breadcrumb = diff --git a/app/utils/sql.ts b/app/utils/sql.ts index cb7dac6be..0bf3ab5a6 100644 --- a/app/utils/sql.ts +++ b/app/utils/sql.ts @@ -20,3 +20,7 @@ export function parseDBArray(value: any) { return parsed; } + +export function booleanToInt(value: boolean) { + return value ? 1 : 0; +} diff --git a/app/utils/strings.ts b/app/utils/strings.ts index 2c8fc092a..a962340e2 100644 --- a/app/utils/strings.ts +++ b/app/utils/strings.ts @@ -7,6 +7,10 @@ export function discordFullName( return `${user.discordName}#${user.discordDiscriminator}`; } +export function inGameNameWithoutDiscriminator(inGameName: string) { + return inGameName.split("#")[0]; +} + export function makeTitle(title: string | string[]) { return `${Array.isArray(title) ? title.join(" | ") : title} | sendou.ink`; } diff --git a/app/utils/urls.ts b/app/utils/urls.ts index 2f26823c3..84b2d5959 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -176,11 +176,29 @@ export const calendarEditPage = (eventId?: number) => `/calendar/new${eventId ? `?eventId=${eventId}` : ""}`; export const calendarReportWinnersPage = (eventId: number) => `/calendar/${eventId}/report-winners`; -export const toToolsPage = (eventId: number) => `/to/${eventId}`; -export const toToolsRegisterPage = (eventId: number) => +export const tournamentPage = (eventId: number) => `/to/${eventId}`; +export const tournamentRegisterPage = (eventId: number) => `/to/${eventId}/register`; -export const toToolsMapsPage = (eventId: number) => `/to/${eventId}/maps`; -export const toToolsJoinPage = ({ +export const tournamentMapsPage = (eventId: number) => `/to/${eventId}/maps`; +export const tournamentBracketsPage = (eventId: number) => + `/to/${eventId}/brackets`; +export const tournamentBracketsSubscribePage = (eventId: number) => + `/to/${eventId}/brackets/subscribe`; +export const tournamentMatchPage = ({ + eventId, + matchId, +}: { + eventId: number; + matchId: number; +}) => `/to/${eventId}/matches/${matchId}`; +export const tournamentMatchSubscribePage = ({ + eventId, + matchId, +}: { + eventId: number; + matchId: number; +}) => `/to/${eventId}/matches/${matchId}/subscribe`; +export const tournamentJoinPage = ({ eventId, inviteCode, }: { diff --git a/e2e/tournament.spec.ts b/e2e/tournament.spec.ts new file mode 100644 index 000000000..b251cff4a --- /dev/null +++ b/e2e/tournament.spec.ts @@ -0,0 +1,194 @@ +import { expect, test } from "@playwright/test"; +import invariant from "tiny-invariant"; +import type { TournamentLoaderData } from "~/features/tournament"; +import { rankedModesShort } from "~/modules/in-game-lists/modes"; +import { + fetchSendouInk, + impersonate, + isNotVisible, + navigate, + seed, + selectUser, + submit, +} from "~/utils/playwright"; +import { tournamentBracketsPage, tournamentPage } from "~/utils/urls"; + +const fetchTournamentLoaderData = () => + fetchSendouInk( + "/to/1/admin?_data=features%2Ftournament%2Froutes%2Fto.%24id" + ); + +const getIsOwnerOfUser = ({ + data, + userId, + teamId, +}: { + data: TournamentLoaderData; + userId: number; + teamId: number; +}) => { + const team = data.teams.find((t) => t.id === teamId); + invariant(team, "Team not found"); + + return team.members.find((m) => m.userId === userId)?.isOwner; +}; + +const getTeamCheckedInAt = ({ + data, + teamId, +}: { + data: TournamentLoaderData; + teamId: number; +}) => { + const team = data.teams.find((t) => t.id === teamId); + invariant(team, "Team not found"); + return team.checkedInAt; +}; + +test.describe("Tournament", () => { + test("registers for tournament", async ({ page }) => { + await seed(page, "NO_TOURNAMENT_TEAMS"); + await impersonate(page); + + await navigate({ + page, + url: tournamentPage(1), + }); + + await page.getByLabel("Team name").type("Chimera"); + await page.getByTestId("save-team-button").click(); + + await page.getByTestId("add-player-button").click(); + await expect(page.getByTestId("member-num-2")).toBeVisible(); + await page.getByTestId("add-player-button").click(); + await expect(page.getByTestId("member-num-3")).toBeVisible(); + await page.getByTestId("add-player-button").click(); + await expect(page.getByTestId("member-num-4")).toBeVisible(); + + let stage = 5; + for (const mode of rankedModesShort) { + for (const num of [1, 2]) { + await page + .getByTestId(`counterpick-map-pool-${mode}-num-${num}`) + .selectOption(String(stage)); + stage++; + } + } + await page.getByTestId("save-map-list-button").click(); + + await expect(page.getByTestId("checkmark-icon-num-3")).toBeVisible(); + }); + + test("checks in and appears on the bracket", async ({ page }) => { + await seed(page); + await impersonate(page); + + await navigate({ + page, + url: tournamentBracketsPage(1), + }); + + await isNotVisible(page.getByText("Chimera")); + + await page.getByTestId("register-tab").click(); + await page.getByTestId("check-in-button").click(); + + await page.getByTestId("brackets-tab").click(); + await page.getByText("#1 Chimera").waitFor(); + }); + + test("operates admin controls", async ({ page }) => { + await seed(page); + await impersonate(page); + + await navigate({ + page, + url: tournamentPage(1), + }); + + await page.getByTestId("admin-tab").click(); + + const actionSelect = page.getByLabel("Action"); + const teamSelect = page.getByLabel("Team"); + const memberSelect = page.getByLabel("Member"); + + // Change team owner + let data = await fetchTournamentLoaderData(); + expect(getIsOwnerOfUser({ data, userId: 1, teamId: 1 })).toBe(1); + + await actionSelect.selectOption("CHANGE_TEAM_OWNER"); + await teamSelect.selectOption("1"); + await memberSelect.selectOption("2"); + await submit(page); + + data = await fetchTournamentLoaderData(); + expect(getIsOwnerOfUser({ data, userId: 1, teamId: 1 })).toBe(0); + expect(getIsOwnerOfUser({ data, userId: 2, teamId: 1 })).toBe(1); + + // Check in team + expect(getTeamCheckedInAt({ data, teamId: 1 })).toBeFalsy(); + + await actionSelect.selectOption("CHECK_IN"); + await submit(page); + + data = await fetchTournamentLoaderData(); + expect(getTeamCheckedInAt({ data, teamId: 1 })).toBeTruthy(); + + // Check out team + await actionSelect.selectOption("CHECK_OUT"); + await submit(page); + + data = await fetchTournamentLoaderData(); + expect(getTeamCheckedInAt({ data, teamId: 1 })).toBeFalsy(); + + // Remove member... + const firstTeam = data.teams.find((t) => t.id === 1); + invariant(firstTeam, "First team not found"); + const firstNonOwnerMember = firstTeam.members.find( + (m) => m.userId !== 1 && !m.isOwner + ); + invariant(firstNonOwnerMember, "First non owner member not found"); + + await actionSelect.selectOption("REMOVE_MEMBER"); + await memberSelect.selectOption(String(firstNonOwnerMember.userId)); + await submit(page); + + data = await fetchTournamentLoaderData(); + const firstTeamAgain = data.teams.find((t) => t.id === 1); + invariant(firstTeamAgain, "First team again not found"); + expect(firstTeamAgain.members.length).toBe(firstTeam.members.length - 1); + + // ...and add to another team + const teamWithSpace = data.teams.find( + (t) => t.id !== 1 && t.members.length === 4 + ); + invariant(teamWithSpace, "Team with space not found"); + + await actionSelect.selectOption("ADD_MEMBER"); + await teamSelect.selectOption(String(teamWithSpace.id)); + await selectUser({ + labelName: "User", + userName: firstNonOwnerMember.discordName, + page, + }); + await submit(page); + + data = await fetchTournamentLoaderData(); + const teamWithSpaceAgain = data.teams.find( + (t) => t.id === teamWithSpace.id + ); + invariant(teamWithSpaceAgain, "Team with space again not found"); + + expect(teamWithSpaceAgain.members.length).toBe( + teamWithSpace.members.length + 1 + ); + + // Remove team + await actionSelect.selectOption("DELETE_TEAM"); + await teamSelect.selectOption("1"); + await submit(page); + + data = await fetchTournamentLoaderData(); + expect(data.teams.find((t) => t.id === 1)).toBeFalsy(); + }); +}); diff --git a/migrations/014-full-tournament.js b/migrations/014-full-tournament.js index aa95216f5..899fe6234 100644 --- a/migrations/014-full-tournament.js +++ b/migrations/014-full-tournament.js @@ -68,7 +68,6 @@ module.exports.up = function (db) { ` ).run(); - // xxx: add some unique constraint here db.prepare( /*sql*/ ` create table "TournamentMatch" ( diff --git a/migrations/026-full-tournament-v2.js b/migrations/026-full-tournament-v2.js new file mode 100644 index 000000000..43caabf0c --- /dev/null +++ b/migrations/026-full-tournament-v2.js @@ -0,0 +1,234 @@ +module.exports.up = function (db) { + db.prepare(/* sql */ `drop index "calendar_event_custom_url_unique"`).run(); + db.prepare(/*sql*/ `drop table "TournamentBracket"`).run(); + db.prepare(/*sql*/ `drop table "TournamentMatchParticipant"`).run(); + db.prepare(/*sql*/ `drop table "TournamentRound"`).run(); + db.prepare(/*sql*/ `drop table "TournamentMatch"`).run(); + db.prepare(/*sql*/ `drop table "TournamentTeam"`).run(); + db.prepare(/*sql*/ `drop table "TournamentTeamMember"`).run(); + db.prepare(/*sql*/ `drop table "TournamentMatchGameResult"`).run(); + + db.prepare( + /* sql */ `alter table "CalendarEvent" drop column "customUrl"` + ).run(); + db.prepare( + /* sql */ `alter table "CalendarEvent" drop column "toToolsEnabled"` + ).run(); + db.prepare( + /* sql */ `alter table "CalendarEvent" drop column "toToolsMode"` + ).run(); + db.prepare( + /* sql */ `alter table "CalendarEvent" drop column "isBeforeStart"` + ).run(); + + db.prepare( + /* sql */ `alter table "CalendarEvent" add "tournamentId" integer` + ).run(); + db.prepare( + /*sql*/ `create index calendar_event_tournament_id on "CalendarEvent"("tournamentId")` + ).run(); + + db.prepare( + /*sql*/ ` + create table "Tournament" ( + "id" integer primary key, + "mapPickingStyle" text not null, + "format" text not null, + "showMapListGenerator" integer default 0 + ) strict + ` + ).run(); + + db.prepare( + /*sql*/ ` + create table "TournamentTeam" ( + "id" integer primary key, + "name" text not null, + "createdAt" integer default (strftime('%s', 'now')) not null, + "seed" integer, + "inviteCode" text not null unique, + "tournamentId" integer not null, + "prefersNotToHost" integer not null default 0, + foreign key ("tournamentId") references "Tournament"("id") on delete cascade, + unique("tournamentId", "name") on conflict rollback + ) strict + ` + ).run(); + db.prepare( + /*sql*/ `create index tournament_team_tournament_id on "TournamentTeam"("tournamentId")` + ).run(); + + db.prepare( + /*sql*/ ` + create table "TournamentTeamCheckIn" ( + "tournamentTeamId" integer not null, + "checkedInAt" integer not null, + foreign key ("tournamentTeamId") references "TournamentTeam"("id") on delete cascade + ) strict + ` + ).run(); + db.prepare( + /*sql*/ `create index tournament_team_check_in_tournament_team_id on "TournamentTeamCheckIn"("tournamentTeamId")` + ).run(); + + db.prepare( + /*sql*/ ` + create table "TournamentTeamMember" ( + "tournamentTeamId" integer not null, + "userId" integer not null, + "isOwner" integer not null default 0, + "createdAt" integer default (strftime('%s', 'now')) not null, + foreign key ("tournamentTeamId") references "TournamentTeam"("id") on delete cascade, + unique("tournamentTeamId", "userId") on conflict rollback + ) strict + ` + ).run(); + db.prepare( + /*sql*/ `create index tournament_team_member_tournament_team_id on "TournamentTeamMember"("tournamentTeamId")` + ).run(); + + db.prepare( + /*sql*/ ` + create table "TournamentStage" ( + "id" integer primary key, + "tournamentId" integer not null, + "name" text not null, + "type" text not null, + "settings" text not null, + "number" integer not null, + foreign key ("tournamentId") references "Tournament"("id") on delete cascade, + unique("number", "tournamentId") on conflict rollback + ) strict + ` + ).run(); + db.prepare( + `create index tournament_stage_tournament_id on "TournamentStage"("tournamentId")` + ).run(); + + db.prepare( + /*sql*/ ` + create table "TournamentGroup" ( + "id" integer primary key, + "stageId" integer not null, + "number" integer not null, + foreign key ("stageId") references "TournamentStage"("id") on delete cascade, + unique("number", "stageId") on conflict rollback + ) strict + ` + ).run(); + db.prepare( + `create index tournament_group_stage_id on "TournamentGroup"("stageId")` + ).run(); + + db.prepare( + /*sql*/ ` + create table "TournamentRound" ( + "id" integer primary key, + "stageId" integer not null, + "groupId" integer not null, + "number" integer not null, + foreign key ("stageId") references "TournamentStage"("id") on delete cascade, + foreign key ("groupId") references "TournamentGroup"("id") on delete cascade, + unique("number", "groupId") on conflict rollback + ) strict + ` + ).run(); + db.prepare( + `create index tournament_round_stage_id on "TournamentRound"("stageId")` + ).run(); + db.prepare( + `create index tournament_round_group_id on "TournamentRound"("groupId")` + ).run(); + + db.prepare( + /*sql*/ ` + create table "TournamentMatch" ( + "id" integer primary key, + "childCount" integer not null, + "bestOf" integer not null default 3, + "roundId" integer not null, + "stageId" integer not null, + "groupId" integer not null, + "number" integer not null, + "opponentOne" text not null, + "opponentTwo" text not null, + "status" integer not null, + foreign key ("roundId") references "TournamentRound"("id") on delete cascade, + foreign key ("stageId") references "TournamentStage"("id") on delete cascade, + foreign key ("groupId") references "TournamentGroup"("id") on delete cascade, + unique("number", "roundId") on conflict rollback + ) strict + ` + ).run(); + db.prepare( + `create index tournament_match_round_id on "TournamentMatch"("roundId")` + ).run(); + db.prepare( + `create index tournament_match_stage_id on "TournamentMatch"("stageId")` + ).run(); + db.prepare( + `create index tournament_match_group_id on "TournamentMatch"("groupId")` + ).run(); + + db.prepare( + /*sql*/ ` + create table "TournamentMatchGameResult" ( + "id" integer primary key, + "matchId" integer not null, + "number" integer not null, + "stageId" integer not null, + "mode" text not null, + "source" text not null, + "winnerTeamId" integer not null, + "reporterId" integer not null, + "createdAt" integer default (strftime('%s', 'now')) not null, + foreign key ("matchId") references "TournamentMatch"("id") on delete cascade, + foreign key ("winnerTeamId") references "TournamentTeam"("id") on delete restrict, + foreign key ("reporterId") references "User"("id") on delete restrict, + unique("matchId", "number") on conflict rollback + ) strict + ` + ).run(); + db.prepare( + /*sql*/ `create index tournament_match_game_result_match_id on "TournamentMatchGameResult"("matchId")` + ).run(); + db.prepare( + /*sql*/ `create index tournament_match_game_result_winner_team_id on "TournamentMatchGameResult"("winnerTeamId")` + ).run(); + + db.prepare( + /*sql*/ ` + create table "TournamentMatchGameResultParticipant" ( + "matchGameResultId" integer not null, + "userId" integer not null, + foreign key ("matchGameResultId") references "TournamentMatchGameResult"("id") on delete cascade, + foreign key ("userId") references "User"("id") on delete cascade, + unique("matchGameResultId", "userId") on conflict rollback + ) strict + ` + ).run(); + db.prepare( + `create index tournament_match_game_result_participant_match_game_result_id on "TournamentMatchGameResultParticipant"("matchGameResultId")` + ).run(); + db.prepare( + `create index tournament_match_game_result_participant_user_id on "TournamentMatchGameResultParticipant"("userId")` + ).run(); + + db.prepare( + /*sql*/ ` + create table "TrustRelationship" ( + "trustGiverUserId" integer not null, + "trustReceiverUserId" integer not null, + foreign key ("trustGiverUserId") references "User"("id") on delete cascade, + foreign key ("trustReceiverUserId") references "User"("id") on delete cascade, + unique("trustGiverUserId", "trustReceiverUserId") on conflict ignore + ) strict + ` + ).run(); + db.prepare( + `create index trust_relationship_trust_giver_user_id on "TrustRelationship"("trustGiverUserId")` + ).run(); + db.prepare( + `create index trust_relationship_trust_receiver_user_id on "TrustRelationship"("trustReceiverUserId")` + ).run(); +}; diff --git a/package-lock.json b/package-lock.json index 62ae03d81..5d864fd2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "sendou.ink", "version": "3.0.0", "dependencies": { + "@dnd-kit/core": "^6.0.8", + "@dnd-kit/sortable": "^7.0.2", + "@dnd-kit/utilities": "^3.2.1", "@faker-js/faker": "^7.6.0", "@headlessui/react": "^1.7.13", "@popperjs/core": "^2.11.7", @@ -17,6 +20,7 @@ "@tldraw/tldraw": "^1.29.2", "aws-sdk": "^2.1354.0", "better-sqlite3": "^8.3.0", + "brackets-model": "^1.4.0", "cachified": "^3.1.0", "clsx": "^1.2.1", "compressorjs": "^1.2.1", @@ -46,6 +50,7 @@ "remix-auth": "^3.4.0", "remix-auth-oauth2": "^1.6.0", "remix-i18next": "^4.1.1", + "remix-utils": "^6.3.0", "slugify": "^1.6.6", "swr": "^2.1.2", "tiny-invariant": "^1.3.1", @@ -1928,6 +1933,55 @@ "postcss-selector-parser": "^6.0.10" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz", + "integrity": "sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.0.8.tgz", + "integrity": "sha512-lYaoP8yHTQSLlZe6Rr9qogouGUz9oRUj4AHhDQGQzq/hqaJRpFo65X+JKsdHf8oUFBzx5A+SJPUvxAwTF2OabA==", + "dependencies": { + "@dnd-kit/accessibility": "^3.0.0", + "@dnd-kit/utilities": "^3.2.1", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-7.0.2.tgz", + "integrity": "sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==", + "dependencies": { + "@dnd-kit/utilities": "^3.2.0", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.0.7", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.1.tgz", + "integrity": "sha512-OOXqISfvBw/1REtkSK2N3Fi2EQiLMlWUlqnOK/UpOISqBZPWpE6TqL+jcPtMOkE8TqYGiURvRdPSI9hltNUjEA==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emotion/hash": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.0.tgz", @@ -5670,6 +5724,11 @@ "node": ">=8" } }, + "node_modules/brackets-model": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/brackets-model/-/brackets-model-1.4.0.tgz", + "integrity": "sha512-bDsM2VTLkhkCZPis7vXNhVB96gYmjO5P7qJg81+7DF0c2OygwqLm5XSeogV+w2i2rVFFjLoUCQEkBI2lr7qZhA==" + }, "node_modules/browser-fs-access": { "version": "0.31.1", "license": "Apache-2.0" @@ -9526,6 +9585,14 @@ "dev": true, "license": "MIT" }, + "node_modules/ip-regex": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-4.3.0.tgz", + "integrity": "sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==", + "engines": { + "node": ">=8" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -9756,6 +9823,17 @@ "node": ">=8" } }, + "node_modules/is-ip": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-ip/-/is-ip-3.1.0.tgz", + "integrity": "sha512-35vd5necO7IitFPjd/YBeqwWnyDWbuLH9ZXQdMfDA8TEo7pv5X8yfrvVO3xbJbLUlERCMvf6X0hTUamQxCYJ9Q==", + "dependencies": { + "ip-regex": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-negative-zero": { "version": "2.0.2", "dev": true, @@ -13177,6 +13255,38 @@ "react-i18next": "^11.13.0" } }, + "node_modules/remix-utils": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/remix-utils/-/remix-utils-6.3.0.tgz", + "integrity": "sha512-ivtBYxsToXYFFzvkVPJiw21a5bbzucJslK2H3McJDjkaGvivSGULdyUmSWwmDhzuAXdUxNOV/MBoWtBnNLCIOg==", + "dependencies": { + "intl-parse-accept-language": "^1.0.0", + "is-ip": "^3.1.0", + "schema-dts": "^1.1.0", + "type-fest": "^2.5.2", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@remix-run/react": "^1.10.0", + "@remix-run/server-runtime": "^1.10.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "zod": "^3.19.1" + } + }, + "node_modules/remix-utils/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "dev": true, @@ -13428,6 +13538,14 @@ "loose-envify": "^1.1.0" } }, + "node_modules/schema-dts": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/schema-dts/-/schema-dts-1.1.2.tgz", + "integrity": "sha512-MpNwH0dZJHinVxk9bT8XUdjKTxMYrA5bLtrrGmFA6PTLwlOKnhi67XoRd6/ty+Djt6ZC0slR57qFhZDNMI6DhQ==", + "peerDependencies": { + "typescript": ">=4.1.0" + } + }, "node_modules/screenfull": { "version": "5.2.0", "license": "MIT", @@ -14827,7 +14945,6 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", - "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17315,6 +17432,41 @@ "dev": true, "requires": {} }, + "@dnd-kit/accessibility": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz", + "integrity": "sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg==", + "requires": { + "tslib": "^2.0.0" + } + }, + "@dnd-kit/core": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.0.8.tgz", + "integrity": "sha512-lYaoP8yHTQSLlZe6Rr9qogouGUz9oRUj4AHhDQGQzq/hqaJRpFo65X+JKsdHf8oUFBzx5A+SJPUvxAwTF2OabA==", + "requires": { + "@dnd-kit/accessibility": "^3.0.0", + "@dnd-kit/utilities": "^3.2.1", + "tslib": "^2.0.0" + } + }, + "@dnd-kit/sortable": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-7.0.2.tgz", + "integrity": "sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==", + "requires": { + "@dnd-kit/utilities": "^3.2.0", + "tslib": "^2.0.0" + } + }, + "@dnd-kit/utilities": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.1.tgz", + "integrity": "sha512-OOXqISfvBw/1REtkSK2N3Fi2EQiLMlWUlqnOK/UpOISqBZPWpE6TqL+jcPtMOkE8TqYGiURvRdPSI9hltNUjEA==", + "requires": { + "tslib": "^2.0.0" + } + }, "@emotion/hash": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.0.tgz", @@ -19766,6 +19918,11 @@ "fill-range": "^7.0.1" } }, + "brackets-model": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/brackets-model/-/brackets-model-1.4.0.tgz", + "integrity": "sha512-bDsM2VTLkhkCZPis7vXNhVB96gYmjO5P7qJg81+7DF0c2OygwqLm5XSeogV+w2i2rVFFjLoUCQEkBI2lr7qZhA==" + }, "browser-fs-access": { "version": "0.31.1" }, @@ -22196,6 +22353,11 @@ "version": "1.1.8", "dev": true }, + "ip-regex": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-4.3.0.tgz", + "integrity": "sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==" + }, "ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -22313,6 +22475,14 @@ "version": "1.0.0", "dev": true }, + "is-ip": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-ip/-/is-ip-3.1.0.tgz", + "integrity": "sha512-35vd5necO7IitFPjd/YBeqwWnyDWbuLH9ZXQdMfDA8TEo7pv5X8yfrvVO3xbJbLUlERCMvf6X0hTUamQxCYJ9Q==", + "requires": { + "ip-regex": "^4.0.0" + } + }, "is-negative-zero": { "version": "2.0.2", "dev": true @@ -24386,6 +24556,25 @@ "use-consistent-value": "^1.0.0" } }, + "remix-utils": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/remix-utils/-/remix-utils-6.3.0.tgz", + "integrity": "sha512-ivtBYxsToXYFFzvkVPJiw21a5bbzucJslK2H3McJDjkaGvivSGULdyUmSWwmDhzuAXdUxNOV/MBoWtBnNLCIOg==", + "requires": { + "intl-parse-accept-language": "^1.0.0", + "is-ip": "^3.1.0", + "schema-dts": "^1.1.0", + "type-fest": "^2.5.2", + "uuid": "^8.3.2" + }, + "dependencies": { + "type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==" + } + } + }, "require-from-string": { "version": "2.0.2", "dev": true @@ -24540,6 +24729,12 @@ "loose-envify": "^1.1.0" } }, + "schema-dts": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/schema-dts/-/schema-dts-1.1.2.tgz", + "integrity": "sha512-MpNwH0dZJHinVxk9bT8XUdjKTxMYrA5bLtrrGmFA6PTLwlOKnhi67XoRd6/ty+Djt6ZC0slR57qFhZDNMI6DhQ==", + "requires": {} + }, "screenfull": { "version": "5.2.0" }, @@ -25476,8 +25671,7 @@ "typescript": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", - "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", - "dev": true + "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==" }, "ufo": { "version": "1.1.1", diff --git a/package.json b/package.json index cadb7d95a..02b942b26 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "dev:ci": "cp .env.example .env && npm run migrate up && npm run dev", "start": "npm run migrate up && remix-serve build", "migrate": "ley", - "migrate:reset": "node scripts/delete-db-files.mjs && npm run migrate && npm run seed", + "migrate:reset": "node scripts/delete-db-files.mjs && npm run migrate up", "add-badge": "node --experimental-specifier-resolution=node --loader ts-node/esm -r tsconfig-paths/register scripts/add-badge.ts", "fix-map-pools": "node --experimental-specifier-resolution=node --loader ts-node/esm -r tsconfig-paths/register scripts/fix-map-pools.ts", "rename-badge": "node --experimental-specifier-resolution=node --loader ts-node/esm -r tsconfig-paths/register scripts/rename-badge.ts", @@ -37,6 +37,9 @@ "cf:noe2e": "npm run test:unit && npm run check-translation-jsons && npm run lint:styles -- --fix && npm run lint:ts -- --fix && npm run prettier:write && npm run typecheck" }, "dependencies": { + "@dnd-kit/core": "^6.0.8", + "@dnd-kit/sortable": "^7.0.2", + "@dnd-kit/utilities": "^3.2.1", "@faker-js/faker": "^7.6.0", "@headlessui/react": "^1.7.13", "@popperjs/core": "^2.11.7", @@ -46,6 +49,7 @@ "@tldraw/tldraw": "^1.29.2", "aws-sdk": "^2.1354.0", "better-sqlite3": "^8.3.0", + "brackets-model": "^1.4.0", "cachified": "^3.1.0", "clsx": "^1.2.1", "compressorjs": "^1.2.1", @@ -75,6 +79,7 @@ "remix-auth": "^3.4.0", "remix-auth-oauth2": "^1.6.0", "remix-i18next": "^4.1.1", + "remix-utils": "^6.3.0", "slugify": "^1.6.6", "swr": "^2.1.2", "tiny-invariant": "^1.3.1", diff --git a/public/locales/da/tournament.json b/public/locales/da/tournament.json index e2056f58d..33edcee67 100644 --- a/public/locales/da/tournament.json +++ b/public/locales/da/tournament.json @@ -39,8 +39,5 @@ "teams.mapsPickedStatus": "Status for banevalg", - "admin.eventStarted": "Begivenheden er startet", - "admin.eventStarted.explanation": "Efter begivenheden er startet, kan hold stadigvæk lave nye banepuljer. De kan dog ikke ændre deres banepulje eller holdlister.", - "admin.download": "Hent liste over deltagere", - "admin.download.discord": "Hent Discord-liste" + "admin.download": "Hent liste over deltagere" } diff --git a/public/locales/de/tournament.json b/public/locales/de/tournament.json index 0df4a97e0..b8bebc3b0 100644 --- a/public/locales/de/tournament.json +++ b/public/locales/de/tournament.json @@ -39,8 +39,5 @@ "teams.mapsPickedStatus": "Status ausgewähler Arenen", - "admin.eventStarted": "Event gestartet", - "admin.eventStarted.explanation": "Nach dem Start können Teams Arenen-Listen erstellen, aber ihre Arenen-Pools und Aufstellungen nicht mehr bearbeiten.", - "admin.download": "Teilnehmerliste herunterladen", - "admin.download.discord": "Discord-Liste" + "admin.download": "Teilnehmerliste herunterladen" } diff --git a/public/locales/en/calendar.json b/public/locales/en/calendar.json index 8458ccb90..1ca581c66 100644 --- a/public/locales/en/calendar.json +++ b/public/locales/en/calendar.json @@ -12,6 +12,7 @@ "members": "Members", "results": "Results", "createMapList": "Create map list", + "from": "From {{author}}", "forms.dates": "Dates", "forms.bracketUrl": "Bracket URL", @@ -62,5 +63,6 @@ "tag.desc.S1": "The game played is Splatoon 1.", "tag.desc.S2": "The game played is Splatoon 2.", "tag.desc.SR": "Salmon Run event.", - "tag.desc.CARDS": "Tableturf Battle event." + "tag.desc.CARDS": "Tableturf Battle event.", + "tag.desc.FULL_TOURNAMENT": "Tournament is hosted on sendou.ink" } diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 13abbb032..682f7ddd6 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -73,6 +73,7 @@ "maps.template.preset.ALL": "All Modes", "maps.template.preset.onlyMode": "Only {{modeName}}", "maps.validation.PICKING": "Pick one stage per mode", + "maps.validation.STAGE_REPEAT_IN_SAME_MODE": "Can't pick the same map twice in the same mode", "maps.validation.NOT_ONE_MAP_PER_MODE": "Pick only one stage per mode", "maps.validation.MAP_REPEATED": "Can't pick the same stage more than once", "maps.validation.MODE_REPEATED": "Can't pick the same mode more than once", @@ -108,6 +109,7 @@ "tag.name.S2": "Splatoon 2", "tag.name.SR": "Salmon Run", "tag.name.CARDS": "Tableturf Battle", + "tag.name.FULL_TOURNAMENT": "Hosted on sendou.ink", "weapon.category.SHOOTERS": "Shooters", "weapon.category.BLASTERS": "Blasters", diff --git a/public/locales/en/tournament.json b/public/locales/en/tournament.json index 6132cc310..e94d7f7f3 100644 --- a/public/locales/en/tournament.json +++ b/public/locales/en/tournament.json @@ -41,8 +41,11 @@ "teams.mapsPickedStatus": "Maps picked status", - "admin.eventStarted": "Event started", - "admin.eventStarted.explanation": "After the start, teams can generate map lists but won't be able to edit their map pools or rosters.", "admin.download": "Download participants", - "admin.download.discord": "Discord list" + "admin.actions.CHANGE_TEAM_OWNER": "Change captain", + "admin.actions.CHECK_IN": "Check in", + "admin.actions.CHECK_OUT": "Check out", + "admin.actions.ADD_MEMBER": "Add member", + "admin.actions.REMOVE_MEMBER": "Remove member", + "admin.actions.DELETE_TEAM": "Delete team" } diff --git a/public/locales/fr/tournament.json b/public/locales/fr/tournament.json index f0d421060..4174bd5a2 100644 --- a/public/locales/fr/tournament.json +++ b/public/locales/fr/tournament.json @@ -39,8 +39,5 @@ "teams.mapsPickedStatus": "Statut du choix de stages", - "admin.eventStarted": "L'événement a commencé", - "admin.eventStarted.explanation": "Après le lancement de l'événement, les équipes peuvent générer des listes de stages mais ne peuvent pas changer leur pool de cartes ou leur roster", - "admin.download": "Télécharger les participants", - "admin.download.discord": "Liste Discord" + "admin.download": "Télécharger les participants" } diff --git a/public/locales/it/tournament.json b/public/locales/it/tournament.json index f46dfdf4f..5bd05c78f 100644 --- a/public/locales/it/tournament.json +++ b/public/locales/it/tournament.json @@ -29,8 +29,5 @@ "pickInfo.default": "Scenario predefinito", "generator.error": "I cambiamenti che hai fatto non sono stati salvati visto che il torneo è già cominciato.", "teams.mapsPickedStatus": "Stato scenari selezionati", - "admin.eventStarted": "Evento iniziato", - "admin.eventStarted.explanation": "Dopo l'inizio dell'evento, le squadre possono generare liste di scenari, ma non sarà possibile cambiare le pool di scenari o gli elenchi.", - "admin.download": "Download partecipanti", - "admin.download.discord": "Lista Discord" + "admin.download": "Download partecipanti" } diff --git a/public/locales/ja/tournament.json b/public/locales/ja/tournament.json index bd6650df1..73b4e34bf 100644 --- a/public/locales/ja/tournament.json +++ b/public/locales/ja/tournament.json @@ -41,8 +41,5 @@ "teams.mapsPickedStatus": "マップの選択状況", - "admin.eventStarted": "イベント開始しました", - "admin.eventStarted.explanation": "イベント開始後、チームはマップリストを生成できますがマップリストや参加メンバーを編集することはできません。", - "admin.download": "参加者リストをダウンロード", - "admin.download.discord": "Discord 一覧をダウンロード" + "admin.download": "参加者リストをダウンロード" } diff --git a/public/locales/pl/tournament.json b/public/locales/pl/tournament.json index d2bdf5d6f..d1f450f07 100644 --- a/public/locales/pl/tournament.json +++ b/public/locales/pl/tournament.json @@ -38,8 +38,5 @@ "generator.error": "Zmiany nie zostały zapisane z powodu rozpoczęcia turnieju", "teams.mapsPickedStatus": "Status wybranych map", - "admin.eventStarted": "Wydarzenie rozpoczęte", - "admin.eventStarted.explanation": "Po rozpoczęciu drużyny mogą generować listy map ale nie będą mogły zmieniać swoją pule map lub ich roster.", - "admin.download": "Zapisz uczestników", - "admin.download.discord": "Lista Discord" + "admin.download": "Zapisz uczestników" } diff --git a/public/locales/ru/tournament.json b/public/locales/ru/tournament.json index c075c6e23..fe3e475ef 100644 --- a/public/locales/ru/tournament.json +++ b/public/locales/ru/tournament.json @@ -39,8 +39,5 @@ "teams.mapsPickedStatus": "Статус выбранных карт", - "admin.eventStarted": "Начать мероприятие", - "admin.eventStarted.explanation": "После начала турнира команды смогут создавать новые списки карт, но редактирование пулов карт или ростеров будет недоступно.", - "admin.download": "Скачать участников", - "admin.download.discord": "Discord список" + "admin.download": "Скачать участников" } diff --git a/public/locales/zh/tournament.json b/public/locales/zh/tournament.json index 95b28bc8d..fec45f1d6 100644 --- a/public/locales/zh/tournament.json +++ b/public/locales/zh/tournament.json @@ -41,8 +41,5 @@ "teams.mapsPickedStatus": "地图的选择情况", - "admin.eventStarted": "比赛开始", - "admin.eventStarted.explanation": "比赛开始后,参赛队伍可以制作地图列表,但是不能更改他们的地图池或者队伍阵容。", - "admin.download": "下载参赛者", - "admin.download.discord": "Discord列表" + "admin.download": "下载参赛者" } diff --git a/remix.config.js b/remix.config.js index e8a6e1540..2bfc829ef 100644 --- a/remix.config.js +++ b/remix.config.js @@ -23,7 +23,25 @@ module.exports = { route("/to/:id/teams", "features/tournament/routes/to.$id.teams.tsx"); route("/to/:id/join", "features/tournament/routes/to.$id.join.tsx"); route("/to/:id/admin", "features/tournament/routes/to.$id.admin.tsx"); + route("/to/:id/seeds", "features/tournament/routes/to.$id.seeds.tsx"); route("/to/:id/maps", "features/tournament/routes/to.$id.maps.tsx"); + + route( + "/to/:id/brackets", + "features/tournament-bracket/routes/to.$id.brackets.tsx" + ); + route( + "/to/:id/brackets/subscribe", + "features/tournament-bracket/routes/to.$id.brackets.subscribe.tsx" + ); + route( + "/to/:id/matches/:mid", + "features/tournament-bracket/routes/to.$id.matches.$mid.tsx" + ); + route( + "/to/:id/matches/:mid/subscribe", + "features/tournament-bracket/routes/to.$id.matches.$mid.subscribe.tsx" + ); }); route("/privacy-policy", "features/info/routes/privacy-policy.tsx"); diff --git a/scripts/create-analyzer-json.ts b/scripts/create-analyzer-json.ts index 78cc0aed9..79a10d939 100644 --- a/scripts/create-analyzer-json.ts +++ b/scripts/create-analyzer-json.ts @@ -303,17 +303,11 @@ function combineSwingsIfSame(params: MainWeaponParams): MainWeaponParams { }; } -// const LEGAL_SUB_INK_SAVE_LV = [0, 1, 2, 3]; function parametersToSubWeaponResult( subWeapon: SubWeapon, params: any ): SubWeaponParams { const SubInkSaveLv = params["SubWeaponSetting"]?.["SubInkSaveLv"] ?? 2; - // xxx: enable when all sub weapons have SubInkSaveLv's - // invariant( - // LEGAL_SUB_INK_SAVE_LV.includes(SubInkSaveLv), - // `Unknown SubInkSaveLv ${SubInkSaveLv} for ${subWeapon.__RowId}` - // ); return { overwrites: resolveSubWeaponOverwrites(subWeapon, params), diff --git a/tsconfig.json b/tsconfig.json index 0be48cb13..35ca3926c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,7 @@ "noEmit": true, "forceConsistentCasingInFileNames": true, "allowJs": false, - "noUncheckedIndexedAccess": true, + "noUncheckedIndexedAccess": false, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true,