From d42a864f774044f7b562e040524547eb4e0ae490 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:59:17 +0300 Subject: [PATCH] /to/:id/admin allow editing tournament team map pool if the format has one Closes #3322 --- .../routes/tournament.$id.teams.upsert.ts | 2 + .../settings/components/ModeMapPoolPicker.tsx | 6 +- .../to.$id.admin.registration.server.ts | 9 ++ .../routes/to.$id.admin.registration.$tid.tsx | 48 ++++++++ ...ament-admin-registration-schemas.server.ts | 46 +++++++ .../tournament-admin-registration-schemas.ts | 6 + .../TournamentTeamRepository.server.ts | 67 +++++++---- .../components/CounterPickMapPoolPicker.tsx | 111 +++++++++++++++++ .../tournament/components/TeamWithRoster.tsx | 6 +- .../tournament/routes/to.$id.register.tsx | 112 ++++-------------- e2e/helpers/counterpick-map-pool.ts | 45 +++++++ .../tournament-admin-registration-page.ts | 24 ++++ .../tournament/tournament-register-page.ts | 27 ++--- e2e/pages/tournament/tournament-team-page.ts | 6 + e2e/tournament-admin.spec.ts | 76 +++++++++++- 15 files changed, 451 insertions(+), 140 deletions(-) create mode 100644 app/features/tournament/components/CounterPickMapPoolPicker.tsx create mode 100644 e2e/helpers/counterpick-map-pool.ts diff --git a/app/features/api-public/routes/tournament.$id.teams.upsert.ts b/app/features/api-public/routes/tournament.$id.teams.upsert.ts index 477dbb008..643a28e7d 100644 --- a/app/features/api-public/routes/tournament.$id.teams.upsert.ts +++ b/app/features/api-public/routes/tournament.$id.teams.upsert.ts @@ -76,6 +76,8 @@ export const action = async (args: ActionFunctionArgs) => { userId: member.userId, inGameName: member.inGameName ?? null, })), + // the API can't edit counterpick maps, so the team's existing pool is carried over as is + mapPool: existingTeam?.mapPool ?? [], }), }); diff --git a/app/features/settings/components/ModeMapPoolPicker.tsx b/app/features/settings/components/ModeMapPoolPicker.tsx index 88eaa1a15..1b24dd886 100644 --- a/app/features/settings/components/ModeMapPoolPicker.tsx +++ b/app/features/settings/components/ModeMapPoolPicker.tsx @@ -186,7 +186,11 @@ function MapButton({ data-testid={testId} /> {selected ? ( - + ) : null} {tiebreaker ? (
Tiebreak
diff --git a/app/features/tournament-admin/actions/to.$id.admin.registration.server.ts b/app/features/tournament-admin/actions/to.$id.admin.registration.server.ts index 8cc1e1267..2377f8e39 100644 --- a/app/features/tournament-admin/actions/to.$id.admin.registration.server.ts +++ b/app/features/tournament-admin/actions/to.$id.admin.registration.server.ts @@ -4,6 +4,7 @@ import { redirect, } from "react-router"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; +import { MapPool } from "~/features/map-list-generator/core/map-pool"; import { notify } from "~/features/notifications/core/notify.server"; import * as TeamRepository from "~/features/team/TeamRepository.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; @@ -102,6 +103,13 @@ export const upsertRegistrationAction = async ( })) : []; + // the map pool field is only shown while it can still be changed, so a submission + // from any other state says nothing about the pool the team has + const mapPool = + tournament.teamsPrePickMaps && !tournament.hasStarted + ? new MapPool(data.mapPool) + : undefined; + const { appliedTournamentNameChanges } = await TournamentTeamRepository.upsertRegistration({ tournamentTeamId: team?.id, @@ -115,6 +123,7 @@ export const upsertRegistrationAction = async ( membersToRemove, inGameNameUpdates, tournamentNameUpdates, + mapPool, }); for (const change of appliedTournamentNameChanges) { diff --git a/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.tsx b/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.tsx index 71b02e4c7..b061454e4 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.tsx @@ -4,13 +4,22 @@ import { useTranslation } from "react-i18next"; import { useFetcher, useLoaderData } from "react-router"; import { LinkButton, SendouButton } from "~/components/elements/Button"; import { SendouDialog } from "~/components/elements/Dialog"; +import { Label } from "~/components/Label"; import { useUser } from "~/features/auth/core/user"; +import { + type CounterPickMapPool, + CounterPickMapPoolPicker, + MapPoolValidationStatusMessage, + useCounterPickMapPoolValidationStatus, +} from "~/features/tournament/components/CounterPickMapPoolPicker"; import { useTournament } from "~/features/tournament/tournament-context"; import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server"; import { FormField } from "~/form/FormField"; +import { FormFieldMessages } from "~/form/fields/FormFieldWrapper"; import { SendouForm, useFormFieldContext } from "~/form/SendouForm"; import type { ArrayItemRenderContext, + CustomFieldRenderProps, SelectOption, TeamSearchFieldOptions, TournamentSearchFieldOptions, @@ -79,6 +88,7 @@ export default function TournamentAdminRegistrationPage() { : null, tournamentName: member.tournamentName ?? null, })), + mapPool: team.mapPool ?? [], } : undefined; @@ -298,10 +308,48 @@ function RegistrationFields({ team }: { team: TournamentTeamFull | null }) { )} + {tournament.teamsPrePickMaps && !tournament.hasStarted ? ( + + {(props: CustomFieldRenderProps) => ( + + )} + + ) : null} ); } +function MapPoolField({ + name, + value, + error, + onChange, + disabled, +}: CustomFieldRenderProps) { + const { t } = useTranslation(["common"]); + const validationStatus = useCounterPickMapPoolValidationStatus(value); + + // the pickers are a group of buttons rather than one control, so there is no + // input for the label to point at + return ( +
+ +
+ + +
+ +
+ ); +} + function ImportTeamSection({ currentTournamentId, onImport, diff --git a/app/features/tournament-admin/tournament-admin-registration-schemas.server.ts b/app/features/tournament-admin/tournament-admin-registration-schemas.server.ts index 6682f3572..bbb0cfd32 100644 --- a/app/features/tournament-admin/tournament-admin-registration-schemas.server.ts +++ b/app/features/tournament-admin/tournament-admin-registration-schemas.server.ts @@ -1,6 +1,12 @@ import { z } from "zod"; import { userIsBanned } from "~/features/ban/core/banned.server"; +import { MapPool } from "~/features/map-list-generator/core/map-pool"; import * as TeamRepository from "~/features/team/TeamRepository.server"; +import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; +import { + isOneModeTournamentOf, + validateCounterPickMapPool, +} from "~/features/tournament/tournament-utils"; import { tournamentTeamNameTaken } from "~/features/tournament/tournament-utils.server"; import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; import * as UserRepository from "~/features/user-page/UserRepository.server"; @@ -49,6 +55,46 @@ export function adminRegistrationFormSchemaServer({ }); } + // the map pool is only written while it can still be changed, matching the + // form field's own visibility, so any other state says nothing about it + if (tournament.teamsPrePickMaps && !tournament.hasStarted) { + const currentMapPool = + typeof data.tournamentTeamId === "number" + ? (( + await TournamentTeamRepository.findMapPoolsByTeamIds([ + data.tournamentTeamId, + ]) + ).get(data.tournamentTeamId) ?? []) + : []; + // a pool valid when picked can stop being valid later (a map gets banned, the + // tie-breaker pool changes), so only a changed pool is held to being valid and + // an untouched one can't block unrelated edits to the team + const mapPoolChanged = + MapPool.serialize(data.mapPool) !== MapPool.serialize(currentMapPool); + + if (mapPoolChanged) { + const invalidMode = data.mapPool.some( + (map) => !tournament.modesIncluded.includes(map.mode), + ); + const status = validateCounterPickMapPool( + new MapPool(data.mapPool), + isOneModeTournamentOf( + tournament.ctx.mapPickingStyle, + tournament.ctx.toSetMapPool, + ), + tournament.ctx.tieBreakerMapPool, + ); + + if (invalidMode || status !== "VALID") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "forms:errors.invalidMapPool", + path: ["mapPool"], + }); + } + } + } + const team = typeof data.tournamentTeamId === "number" ? tournament.teamById(data.tournamentTeamId) diff --git a/app/features/tournament-admin/tournament-admin-registration-schemas.ts b/app/features/tournament-admin/tournament-admin-registration-schemas.ts index f79186fdc..b1720aa69 100644 --- a/app/features/tournament-admin/tournament-admin-registration-schemas.ts +++ b/app/features/tournament-admin/tournament-admin-registration-schemas.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { TOURNAMENT } from "~/features/tournament/tournament-constants"; import { array, + customField, fieldset, idConstantOptional, image, @@ -13,6 +14,7 @@ import { tournamentSearchOptional, userSearch, } from "~/form/fields"; +import { modeShort, stageId } from "~/utils/zod"; import { IN_GAME_NAME_MAX_LENGTH } from "../user-page/in-game-name"; import { USER } from "../user-page/user-page-constants"; /** @@ -64,6 +66,10 @@ export const adminRegistrationFormSchema = z max: ADMIN_REGISTRATION_MAX_MEMBERS, field: memberFieldset, }), + mapPool: customField( + { initialValue: [] }, + z.array(z.object({ mode: modeShort, stageId })), + ), }) .superRefine((data, ctx) => { if (data.linkedTeam) { diff --git a/app/features/tournament/TournamentTeamRepository.server.ts b/app/features/tournament/TournamentTeamRepository.server.ts index cdab4acc8..59953bf84 100644 --- a/app/features/tournament/TournamentTeamRepository.server.ts +++ b/app/features/tournament/TournamentTeamRepository.server.ts @@ -211,11 +211,11 @@ export function insert({ /** * Creates a new registration or applies a full-state edit to an existing one in a * single transaction: team name, linked sendou.ink team, owner assignment/transfer, - * member adds/removes, in-game name updates and tournament name updates. Pass - * `tournamentTeamId` to edit an existing team, or omit it to create a new one (all - * members are then "added" and `ownerUserId` becomes the owner). The caller is - * responsible for validating the derived ops and for any side effects (cache updates, - * notifications) outside the transaction. + * member adds/removes, in-game name updates, tournament name updates and the + * counterpick map pool. Pass `tournamentTeamId` to edit an existing team, or omit it + * to create a new one (all members are then "added" and `ownerUserId` becomes the + * owner). The caller is responsible for validating the derived ops and for any side + * effects (cache updates, notifications) outside the transaction. * * Returns the tournament name changes that were actually applied (submitted values * equal to the user's current one are no-ops), for the caller to log. @@ -232,6 +232,7 @@ export function upsertRegistration({ membersToRemove, inGameNameUpdates, tournamentNameUpdates, + mapPool, }: { /** Present when editing an existing team, omitted when creating a new one. */ tournamentTeamId?: number; @@ -253,6 +254,8 @@ export function upsertRegistration({ userId: number; tournamentName: string | null; }>; + /** Counterpick map pool to replace the team's with. Omitted leaves it as is. */ + mapPool?: MapPool; }) { const isNew = typeof tournamentTeamId !== "number"; @@ -301,6 +304,10 @@ export function upsertRegistration({ }); } + if (mapPool) { + await replaceCounterpickMaps(trx, { tournamentTeamId: id, mapPool }); + } + for (const userId of membersToRemove) { await TournamentAuditLogRepository.insert( { @@ -956,30 +963,40 @@ export function leave({ }); } -export function upsertCounterpickMaps({ - tournamentTeamId, - mapPool, -}: { +export function upsertCounterpickMaps(args: { tournamentTeamId: Tables["TournamentTeam"]["id"]; mapPool: MapPool; }) { - return db.transaction().execute(async (trx) => { - await trx - .deleteFrom("MapPoolMap") - .where("MapPoolMap.tournamentTeamId", "=", tournamentTeamId) - .execute(); + return db.transaction().execute((trx) => replaceCounterpickMaps(trx, args)); +} - await trx - .insertInto("MapPoolMap") - .values( - mapPool.stageModePairs.map(({ stageId, mode }) => ({ - tournamentTeamId, - stageId, - mode, - })), - ) - .execute(); - }); +async function replaceCounterpickMaps( + trx: Transaction, + { + tournamentTeamId, + mapPool, + }: { + tournamentTeamId: Tables["TournamentTeam"]["id"]; + mapPool: MapPool; + }, +) { + await trx + .deleteFrom("MapPoolMap") + .where("MapPoolMap.tournamentTeamId", "=", tournamentTeamId) + .execute(); + + if (mapPool.stageModePairs.length === 0) return; + + await trx + .insertInto("MapPoolMap") + .values( + mapPool.stageModePairs.map(({ stageId, mode }) => ({ + tournamentTeamId, + stageId, + mode, + })), + ) + .execute(); } async function findTeamRecentMaps(teamId: number, limit: number) { diff --git a/app/features/tournament/components/CounterPickMapPoolPicker.tsx b/app/features/tournament/components/CounterPickMapPoolPicker.tsx new file mode 100644 index 000000000..e9ca7d28c --- /dev/null +++ b/app/features/tournament/components/CounterPickMapPoolPicker.tsx @@ -0,0 +1,111 @@ +import { useTranslation } from "react-i18next"; +import { Alert } from "~/components/Alert"; +import { MapPool } from "~/features/map-list-generator/core/map-pool"; +import { ModeMapPoolPicker } from "~/features/settings/components/ModeMapPoolPicker"; +import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; +import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; +import { TOURNAMENT } from "../tournament-constants"; +import { useTournament } from "../tournament-context"; +import { + type CounterPickValidationStatus, + isOneModeTournamentOf, + validateCounterPickMapPool, +} from "../tournament-utils"; + +export type CounterPickMapPool = Array<{ + mode: ModeShort; + stageId: StageId; +}>; + +/** Picker for the counterpick maps of a team, one picker per mode the tournament is played on. */ +export function CounterPickMapPoolPicker({ + mapPool, + onChange, + disabled, +}: { + mapPool: CounterPickMapPool; + onChange: (mapPool: CounterPickMapPool) => void; + disabled?: boolean; +}) { + const tournament = useTournament(); + const oneModeOnlyFor = oneModeTournamentOf(tournament); + + return ( + <> + {tournament.modesIncluded.map((mode) => ( + stage.mode === mode, + )?.stageId + } + pool={mapPool + .filter((map) => map.mode === mode) + .map((map) => map.stageId)} + onChange={(stageIds) => + onChange([ + ...mapPool.filter((map) => map.mode !== mode), + ...stageIds.map((stageId) => ({ mode, stageId })), + ]) + } + disabled={disabled} + /> + ))} + + ); +} + +/** Validates a counterpick map pool against the tournament being viewed. */ +export function useCounterPickMapPoolValidationStatus( + mapPool: CounterPickMapPool, +) { + const tournament = useTournament(); + + return validateCounterPickMapPool( + new MapPool(mapPool), + oneModeTournamentOf(tournament), + tournament.ctx.tieBreakerMapPool, + ); +} + +/** Explains why a counterpick map pool can't be saved. Renders nothing for statuses without an explanation. */ +export function MapPoolValidationStatusMessage({ + status, +}: { + status: CounterPickValidationStatus; +}) { + const { t } = useTranslation(["common"]); + + if ( + status !== "TOO_MUCH_STAGE_REPEAT" && + status !== "STAGE_REPEAT_IN_SAME_MODE" && + status !== "INCLUDES_BANNED" && + status !== "INCLUDES_TIEBREAKER" + ) { + return null; + } + + return ( +
+ + {t(`common:maps.validation.${status}`, { + maxStageRepeat: TOURNAMENT.COUNTERPICK_MAX_STAGE_REPEAT, + })} + +
+ ); +} + +function oneModeTournamentOf(tournament: Tournament): ModeShort | null { + return isOneModeTournamentOf( + tournament.ctx.mapPickingStyle, + tournament.ctx.toSetMapPool, + ); +} diff --git a/app/features/tournament/components/TeamWithRoster.tsx b/app/features/tournament/components/TeamWithRoster.tsx index c9227fc9e..720951faf 100644 --- a/app/features/tournament/components/TeamWithRoster.tsx +++ b/app/features/tournament/components/TeamWithRoster.tsx @@ -148,7 +148,11 @@ function TeamMapPool({ {mapPool.map(({ mode, stageId }, i) => { return (
- +
diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index d1df10b9e..ba6211ae2 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -16,8 +16,12 @@ import { containerClassName } from "~/components/Main"; import { SubmitButton } from "~/components/SubmitButton"; import { Config } from "~/config"; import { useUser } from "~/features/auth/core/user"; -import { MapPool } from "~/features/map-list-generator/core/map-pool"; -import { ModeMapPoolPicker } from "~/features/settings/components/ModeMapPoolPicker"; +import { + type CounterPickMapPool, + CounterPickMapPoolPicker, + MapPoolValidationStatusMessage, + useCounterPickMapPoolValidationStatus, +} from "~/features/tournament/components/CounterPickMapPoolPicker"; import { useTournament } from "~/features/tournament/tournament-context"; import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server"; import { FormField } from "~/form/FormField"; @@ -26,7 +30,6 @@ import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; import { useAutoRerender } from "~/hooks/useAutoRerender"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useHydrated } from "~/hooks/useHydrated"; -import { rankedModesShort } from "~/modules/in-game-lists/modes"; import { LOG_IN_URL, SENDOU_INK_BASE_URL, @@ -37,7 +40,6 @@ import { action } from "../actions/to.$id.register.server"; import type { TournamentRegisterPageLoader } from "../loaders/to.$id.register.server"; import { loader } from "../loaders/to.$id.register.server"; import styles from "../tournament.module.css"; -import { TOURNAMENT } from "../tournament-constants"; import { type RegisterTeamFormValues, registerTeamFormSchema, @@ -48,10 +50,6 @@ import { deleteTeamMemberSchema, updateMapPoolSchema, } from "../tournament-schemas"; -import { - type CounterPickValidationStatus, - validateCounterPickMapPool, -} from "../tournament-utils"; export { action, loader }; @@ -228,7 +226,7 @@ function RegistrationForms({ readOnly = false }: { readOnly?: boolean }) { <> {tournament.teamsPrePickMaps ? ( - + ) : null} ) : null} @@ -256,7 +254,7 @@ function ReadOnlyRegistrationForms() { {tournament.teamsPrePickMaps ? ( - + ) : null}
); @@ -916,7 +914,7 @@ function DeleteMember({ members }: { members: TournamentTeamFull["members"] }) { ); } -function CounterPickMapPoolPicker({ +function TeamCounterPickMapPoolPicker({ readOnly = false, mapPool, }: { @@ -924,17 +922,13 @@ function CounterPickMapPoolPicker({ mapPool?: NonNullable; }) { const { t } = useTranslation(["common", "game-misc", "tournament"]); - const tournament = useTournament(); const fetcher = useFetcher(); const data = useLoaderData(); - const [counterPickMaps, setCounterPickMaps] = React.useState( - mapPool ?? data?.mapPool ?? [], - ); + const [counterPickMaps, setCounterPickMaps] = + React.useState(mapPool ?? data?.mapPool ?? []); - const counterPickMapPool = new MapPool(counterPickMaps); - - const isOneModeTournamentOf = - tournament.modesIncluded.length === 1 ? tournament.modesIncluded[0] : null; + const validationStatus = + useCounterPickMapPoolValidationStatus(counterPickMaps); return (
@@ -948,46 +942,12 @@ function CounterPickMapPoolPicker({ name="mapPool" value={JSON.stringify(counterPickMaps)} /> - {rankedModesShort - .filter( - (mode) => - !isOneModeTournamentOf || isOneModeTournamentOf === mode, - ) - .map((mode) => { - return ( - stage.mode === mode, - )?.stageId - } - pool={ - counterPickMaps - .filter((m) => m.mode === mode) - .map((m) => m.stageId) ?? [] - } - onChange={(stageIds) => - setCounterPickMaps([ - ...counterPickMaps.filter((m) => m.mode !== mode), - ...stageIds.map((stageId) => ({ mode, stageId })), - ]) - } - disabled={readOnly} - /> - ); - })} - {readOnly ? null : validateCounterPickMapPool( - counterPickMapPool, - isOneModeTournamentOf, - tournament.ctx.tieBreakerMapPool, - ) === "VALID" ? ( + + {readOnly ? null : validationStatus === "VALID" ? ( ) : ( - + )}
); } - -function MapPoolValidationStatusMessage({ - status, -}: { - status: CounterPickValidationStatus; -}) { - const { t } = useTranslation(["common"]); - - if ( - status !== "TOO_MUCH_STAGE_REPEAT" && - status !== "STAGE_REPEAT_IN_SAME_MODE" && - status !== "INCLUDES_BANNED" && - status !== "INCLUDES_TIEBREAKER" - ) - return null; - - return ( -
- - {t(`common:maps.validation.${status}`, { - maxStageRepeat: TOURNAMENT.COUNTERPICK_MAX_STAGE_REPEAT, - })} - -
- ); -} diff --git a/e2e/helpers/counterpick-map-pool.ts b/e2e/helpers/counterpick-map-pool.ts new file mode 100644 index 000000000..dbca8bc6a --- /dev/null +++ b/e2e/helpers/counterpick-map-pool.ts @@ -0,0 +1,45 @@ +import type { Page } from "@playwright/test"; +import { BANNED_MAPS } from "~/features/match-profile/banned-maps"; +import { TOURNAMENT } from "~/features/tournament/tournament-constants"; +import { rankedModesShort } from "~/modules/in-game-lists/modes"; +import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; + +/** Stage the counterpick picking starts from, leaving the lowest ids to tiebreakers. */ +const FIRST_COUNTERPICK_STAGE_ID = 5; + +/** A stage of a `ModeMapPoolPicker`, whether picked or not. */ +export function counterpickMap(page: Page, mode: ModeShort, stageId: StageId) { + return page.getByTestId(`map-pool-${mode}-${stageId}`); +} + +/** The check overlaying a picked stage. It covers the stage itself, so unpicking goes through it. */ +export function pickedCounterpickMap( + page: Page, + mode: ModeShort, + stageId: StageId, +) { + return page.getByTestId(`map-pool-${mode}-${stageId}-picked`); +} + +/** + * Picks the required amount of counterpick maps for every ranked mode, skipping + * banned ones, and returns what was picked. + */ +export async function pickCounterpickMaps(page: Page) { + const picked: Array<{ mode: ModeShort; stageId: StageId }> = []; + let stageId = FIRST_COUNTERPICK_STAGE_ID; + + for (const mode of rankedModesShort) { + for (let i = 0; i < TOURNAMENT.COUNTERPICK_MAPS_PER_MODE; i++) { + while (BANNED_MAPS[mode].includes(stageId as StageId)) { + stageId++; + } + + await counterpickMap(page, mode, stageId as StageId).click(); + picked.push({ mode, stageId: stageId as StageId }); + stageId++; + } + } + + return picked; +} diff --git a/e2e/pages/tournament/tournament-admin-registration-page.ts b/e2e/pages/tournament/tournament-admin-registration-page.ts index 93d1422ee..141332dc4 100644 --- a/e2e/pages/tournament/tournament-admin-registration-page.ts +++ b/e2e/pages/tournament/tournament-admin-registration-page.ts @@ -1,9 +1,15 @@ import { expect, type Page } from "@playwright/test"; import { adminRegistrationFormSchema } from "~/features/tournament-admin/tournament-admin-registration-schemas"; +import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { tournamentAdminRegistrationEditPage, tournamentAdminRegistrationPage, } from "~/utils/urls"; +import { + counterpickMap, + pickCounterpickMaps, + pickedCounterpickMap, +} from "../../helpers/counterpick-map-pool"; import { navigate, selectTournament, @@ -27,6 +33,7 @@ export class TournamentAdminRegistrationPage { teamNameInput: page.getByLabel("Team name"), importTeamButton: page.getByRole("button", { name: "Import team" }), importDialogHeading: page.getByRole("heading", { name: "Import team" }), + invalidMapPoolError: page.getByText("Invalid map pool"), }; } @@ -75,6 +82,23 @@ export class TournamentAdminRegistrationPage { return submit(this.page); } + /** Picks the required amount of counterpick maps for every mode, skipping banned ones. */ + pickCounterpickMaps() { + return pickCounterpickMaps(this.page); + } + + pickCounterpickMap(mode: ModeShort, stageId: StageId) { + return counterpickMap(this.page, mode, stageId).click(); + } + + unpickCounterpickMap(mode: ModeShort, stageId: StageId) { + return pickedCounterpickMap(this.page, mode, stageId).click(); + } + + pickedCounterpickMap(mode: ModeShort, stageId: StageId) { + return pickedCounterpickMap(this.page, mode, stageId); + } + async openImportDialog() { await this.locators.importTeamButton.click(); } diff --git a/e2e/pages/tournament/tournament-register-page.ts b/e2e/pages/tournament/tournament-register-page.ts index 4732f6f3d..9b1e6166c 100644 --- a/e2e/pages/tournament/tournament-register-page.ts +++ b/e2e/pages/tournament/tournament-register-page.ts @@ -1,17 +1,15 @@ import type { Page } from "@playwright/test"; -import { BANNED_MAPS } from "~/features/match-profile/banned-maps"; -import { TOURNAMENT } from "~/features/tournament/tournament-constants"; import { registerTeamFormSchema } from "~/features/tournament/tournament-register-schemas"; -import { rankedModesShort } from "~/modules/in-game-lists/modes"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { tournamentRegisterPage } from "~/utils/urls"; +import { + counterpickMap, + pickCounterpickMaps, +} from "../../helpers/counterpick-map-pool"; import { navigate, submit } from "../../helpers/playwright"; import { createFormHelpers } from "../../helpers/playwright-form"; import { TournamentNav } from "./tournament-nav"; -/** Stage the counterpick picking starts from, leaving the lowest ids to tiebreakers. */ -const FIRST_COUNTERPICK_STAGE_ID = 5; - export class TournamentRegisterPage { private readonly page: Page; readonly nav; @@ -52,7 +50,7 @@ export class TournamentRegisterPage { } counterpickMap(mode: ModeShort, stageId: StageId) { - return this.page.getByTestId(`map-pool-${mode}-${stageId}`); + return counterpickMap(this.page, mode, stageId); } addPlayer() { @@ -60,19 +58,8 @@ export class TournamentRegisterPage { } /** Picks the required amount of counterpick maps for every mode, skipping banned ones. */ - async pickCounterpickMaps() { - let stageId = FIRST_COUNTERPICK_STAGE_ID; - - for (const mode of rankedModesShort) { - for (let i = 0; i < TOURNAMENT.COUNTERPICK_MAPS_PER_MODE; i++) { - while (BANNED_MAPS[mode].includes(stageId as StageId)) { - stageId++; - } - - await this.counterpickMap(mode, stageId as StageId).click(); - stageId++; - } - } + pickCounterpickMaps() { + return pickCounterpickMaps(this.page); } saveCounterpickMaps() { diff --git a/e2e/pages/tournament/tournament-team-page.ts b/e2e/pages/tournament/tournament-team-page.ts index 095e42bbb..f5bc035ba 100644 --- a/e2e/pages/tournament/tournament-team-page.ts +++ b/e2e/pages/tournament/tournament-team-page.ts @@ -1,4 +1,5 @@ import type { Page } from "@playwright/test"; +import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { tournamentTeamPage } from "~/utils/urls"; import { navigate } from "../../helpers/playwright"; @@ -19,4 +20,9 @@ export class TournamentTeamPage { url: tournamentTeamPage({ tournamentId, tournamentTeamId }), }); } + + /** A map of the team's counterpick map pool, shown to organizers before the tournament starts. */ + mapPoolStage(mode: ModeShort, stageId: StageId) { + return this.page.getByTestId(`team-map-pool-${mode}-${stageId}`); + } } diff --git a/e2e/tournament-admin.spec.ts b/e2e/tournament-admin.spec.ts index 908e57537..a6afb070d 100644 --- a/e2e/tournament-admin.spec.ts +++ b/e2e/tournament-admin.spec.ts @@ -1,6 +1,7 @@ import * as fs from "node:fs/promises"; import { addMinutes, subDays } from "date-fns"; import { NZAP_TEST_ID } from "~/db/seed/constants"; +import type { TournamentMapPickingStyle } from "~/features/tournament/tournament-constants"; import { dateToDatabaseTimestamp } from "~/utils/dates"; import type { Factories } from "./helpers/factories"; import { expect, impersonate, test } from "./helpers/playwright"; @@ -19,6 +20,8 @@ import { TournamentTeamPage } from "./pages/tournament/tournament-team-page"; const ROSTER_SIZE = 4; const CAPTAIN_DISCORD_ID = "1234567890123456789"; +/** Stage above the ones the counterpick picking helper uses, so swapping to it is always a change. */ +const REPLACEMENT_STAGE_ID = 17; test.describe("Tournament admin team management", () => { test("edits a registration, checks a team in and out, unregisters it and records it in the audit log", async ({ @@ -138,6 +141,71 @@ test.describe("Tournament admin team management", () => { ); }); + test("sets the counterpick map pool of a team that has none, rejects an incomplete edit to it and then edits it", async ({ + page, + factories, + }) => { + // teams pre-pick their maps, so the registration form has a map pool to edit + const tournament = await createTournament(factories, { + mapPickingStyle: "AUTO_ALL", + }); + const roster = await factories.UserFactory.createMany(ROSTER_SIZE); + const team = await factories.TournamentTeamFactory.create({ + tournamentId: tournament.id, + team: pickUpTeam("Poolless Pandas"), + memberUserIds: roster.map((user) => user.id), + }); + + await impersonate(page, NZAP_TEST_ID); + + const registration = new TournamentAdminRegistrationPage(page); + await registration.gotoEdit(tournament.id, team.id); + await expect(registration.locators.editHeading).toBeVisible(); + + const picked = await registration.pickCounterpickMaps(); + await registration.save(); + + const admin = new TournamentAdminPage(page); + await expect(admin.locators.searchInput).toBeVisible(); + + const teamPage = new TournamentTeamPage(page); + await teamPage.goto(tournament.id, team.id); + for (const { mode, stageId } of picked) { + await expect(teamPage.mapPoolStage(mode, stageId)).toBeVisible(); + } + + // a pool left incomplete is rejected instead of overwriting the saved one + const [replaced] = picked; + await registration.gotoEdit(tournament.id, team.id); + await expect( + registration.pickedCounterpickMap(replaced.mode, replaced.stageId), + ).toBeVisible(); + await registration.unpickCounterpickMap(replaced.mode, replaced.stageId); + await registration.save(); + await expect(registration.locators.invalidMapPoolError).toBeVisible(); + + await teamPage.goto(tournament.id, team.id); + await expect( + teamPage.mapPoolStage(replaced.mode, replaced.stageId), + ).toBeVisible(); + + // swapping one of the picked maps for another is reflected on the team page + await registration.gotoEdit(tournament.id, team.id); + await registration.unpickCounterpickMap(replaced.mode, replaced.stageId); + await registration.pickCounterpickMap(replaced.mode, REPLACEMENT_STAGE_ID); + await registration.save(); + + await expect(admin.locators.searchInput).toBeVisible(); + + await teamPage.goto(tournament.id, team.id); + await expect( + teamPage.mapPoolStage(replaced.mode, REPLACEMENT_STAGE_ID), + ).toBeVisible(); + await expect( + teamPage.mapPoolStage(replaced.mode, replaced.stageId), + ).toHaveCount(0); + }); + test("exports the team list", async ({ page, factories }) => { const tournament = await createTournament(factories); const roster = await factories.UserFactory.createMany(ROSTER_SIZE); @@ -325,7 +393,11 @@ async function createTournament( factories: Factories, { establishedOrganization = false, - }: { establishedOrganization?: boolean } = {}, + mapPickingStyle, + }: { + establishedOrganization?: boolean; + mapPickingStyle?: TournamentMapPickingStyle; + } = {}, ) { const organization = establishedOrganization ? await factories.TournamentOrganizationFactory.create( @@ -338,6 +410,8 @@ async function createTournament( authorId: NZAP_TEST_ID, organizationId: organization?.id ?? null, startTimes: [dateToDatabaseTimestamp(addMinutes(new Date(), 30))], + // spread so the factory's own default is not overwritten with undefined + ...(mapPickingStyle ? { mapPickingStyle } : {}), }); }