mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 15:48:23 -05:00
/to/:id/admin allow editing tournament team map pool if the format has one
Closes #3322
This commit is contained in:
@@ -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 ?? [],
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -186,7 +186,11 @@ function MapButton({
|
||||
data-testid={testId}
|
||||
/>
|
||||
{selected ? (
|
||||
<Check className={styles.mapButtonIcon} onClick={onClick} />
|
||||
<Check
|
||||
className={styles.mapButtonIcon}
|
||||
onClick={onClick}
|
||||
data-testid={`${testId}-picked`}
|
||||
/>
|
||||
) : null}
|
||||
{tiebreaker ? (
|
||||
<div className={styles.mapButtonText}>Tiebreak</div>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }) {
|
||||
)}
|
||||
</FormField>
|
||||
<FormField name="ownerId" options={ownerOptions} />
|
||||
{tournament.teamsPrePickMaps && !tournament.hasStarted ? (
|
||||
<FormField name="mapPool">
|
||||
{(props: CustomFieldRenderProps) => (
|
||||
<MapPoolField
|
||||
{...props}
|
||||
value={props.value as CounterPickMapPool}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MapPoolField({
|
||||
name,
|
||||
value,
|
||||
error,
|
||||
onChange,
|
||||
disabled,
|
||||
}: CustomFieldRenderProps<CounterPickMapPool>) {
|
||||
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 (
|
||||
<div className="stack sm">
|
||||
<Label spaced={false}>{t("common:maps.mapPool")}</Label>
|
||||
<div className="stack lg">
|
||||
<CounterPickMapPoolPicker
|
||||
mapPool={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<MapPoolValidationStatusMessage status={validationStatus} />
|
||||
</div>
|
||||
<FormFieldMessages name={name} error={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportTeamSection({
|
||||
currentTournamentId,
|
||||
onImport,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<DB>,
|
||||
{
|
||||
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) {
|
||||
|
||||
111
app/features/tournament/components/CounterPickMapPoolPicker.tsx
Normal file
111
app/features/tournament/components/CounterPickMapPoolPicker.tsx
Normal file
@@ -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) => (
|
||||
<ModeMapPoolPicker
|
||||
key={mode}
|
||||
amountToPick={
|
||||
oneModeOnlyFor
|
||||
? TOURNAMENT.COUNTERPICK_ONE_MODE_TOURNAMENT_MAPS_PER_MODE
|
||||
: TOURNAMENT.COUNTERPICK_MAPS_PER_MODE
|
||||
}
|
||||
mode={mode}
|
||||
tiebreaker={
|
||||
tournament.ctx.tieBreakerMapPool.find(
|
||||
(stage) => 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 (
|
||||
<div className="mt-4">
|
||||
<Alert alertClassName="w-max" variation="WARNING" tiny>
|
||||
{t(`common:maps.validation.${status}`, {
|
||||
maxStageRepeat: TOURNAMENT.COUNTERPICK_MAX_STAGE_REPEAT,
|
||||
})}
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function oneModeTournamentOf(tournament: Tournament): ModeShort | null {
|
||||
return isOneModeTournamentOf(
|
||||
tournament.ctx.mapPickingStyle,
|
||||
tournament.ctx.toSetMapPool,
|
||||
);
|
||||
}
|
||||
@@ -148,7 +148,11 @@ function TeamMapPool({
|
||||
{mapPool.map(({ mode, stageId }, i) => {
|
||||
return (
|
||||
<div key={i}>
|
||||
<StageImage stageId={stageId} width={85} />
|
||||
<StageImage
|
||||
stageId={stageId}
|
||||
width={85}
|
||||
testId={`team-map-pool-${mode}-${stageId}`}
|
||||
/>
|
||||
<div className={styles.teamWithRosterMapPoolModeInfo}>
|
||||
<ModeImage mode={mode} size={16} />
|
||||
</div>
|
||||
|
||||
@@ -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 }) {
|
||||
<>
|
||||
<FillRoster ownTeam={ownTeam} ownTeamCheckedIn={ownTeamCheckedIn} />
|
||||
{tournament.teamsPrePickMaps ? (
|
||||
<CounterPickMapPoolPicker key={tournament.ctx.id} />
|
||||
<TeamCounterPickMapPoolPicker key={tournament.ctx.id} />
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
@@ -256,7 +254,7 @@ function ReadOnlyRegistrationForms() {
|
||||
<TeamInfo ownTeam={team} canUnregister={false} readOnly />
|
||||
<FillRoster ownTeam={team} ownTeamCheckedIn={checkedIn} readOnly />
|
||||
{tournament.teamsPrePickMaps ? (
|
||||
<CounterPickMapPoolPicker readOnly mapPool={team.mapPool ?? []} />
|
||||
<TeamCounterPickMapPoolPicker readOnly mapPool={team.mapPool ?? []} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
@@ -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<TournamentTeamFull["mapPool"]>;
|
||||
}) {
|
||||
const { t } = useTranslation(["common", "game-misc", "tournament"]);
|
||||
const tournament = useTournament();
|
||||
const fetcher = useFetcher();
|
||||
const data = useLoaderData<TournamentRegisterPageLoader>();
|
||||
const [counterPickMaps, setCounterPickMaps] = React.useState(
|
||||
mapPool ?? data?.mapPool ?? [],
|
||||
);
|
||||
const [counterPickMaps, setCounterPickMaps] =
|
||||
React.useState<CounterPickMapPool>(mapPool ?? data?.mapPool ?? []);
|
||||
|
||||
const counterPickMapPool = new MapPool(counterPickMaps);
|
||||
|
||||
const isOneModeTournamentOf =
|
||||
tournament.modesIncluded.length === 1 ? tournament.modesIncluded[0] : null;
|
||||
const validationStatus =
|
||||
useCounterPickMapPoolValidationStatus(counterPickMaps);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -948,46 +942,12 @@ function CounterPickMapPoolPicker({
|
||||
name="mapPool"
|
||||
value={JSON.stringify(counterPickMaps)}
|
||||
/>
|
||||
{rankedModesShort
|
||||
.filter(
|
||||
(mode) =>
|
||||
!isOneModeTournamentOf || isOneModeTournamentOf === mode,
|
||||
)
|
||||
.map((mode) => {
|
||||
return (
|
||||
<ModeMapPoolPicker
|
||||
key={mode}
|
||||
amountToPick={
|
||||
isOneModeTournamentOf
|
||||
? TOURNAMENT.COUNTERPICK_ONE_MODE_TOURNAMENT_MAPS_PER_MODE
|
||||
: TOURNAMENT.COUNTERPICK_MAPS_PER_MODE
|
||||
}
|
||||
mode={mode}
|
||||
tiebreaker={
|
||||
tournament.ctx.tieBreakerMapPool.find(
|
||||
(stage) => 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" ? (
|
||||
<CounterPickMapPoolPicker
|
||||
mapPool={counterPickMaps}
|
||||
onChange={setCounterPickMaps}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
{readOnly ? null : validationStatus === "VALID" ? (
|
||||
<SubmitButton
|
||||
schema={updateMapPoolSchema}
|
||||
_action="UPDATE_MAP_POOL"
|
||||
@@ -998,42 +958,10 @@ function CounterPickMapPoolPicker({
|
||||
{t("common:actions.save")}
|
||||
</SubmitButton>
|
||||
) : (
|
||||
<MapPoolValidationStatusMessage
|
||||
status={validateCounterPickMapPool(
|
||||
counterPickMapPool,
|
||||
isOneModeTournamentOf,
|
||||
tournament.ctx.tieBreakerMapPool,
|
||||
)}
|
||||
/>
|
||||
<MapPoolValidationStatusMessage status={validationStatus} />
|
||||
)}
|
||||
</fetcher.Form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mt-4">
|
||||
<Alert alertClassName="w-max" variation="WARNING" tiny>
|
||||
{t(`common:maps.validation.${status}`, {
|
||||
maxStageRepeat: TOURNAMENT.COUNTERPICK_MAX_STAGE_REPEAT,
|
||||
})}
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
45
e2e/helpers/counterpick-map-pool.ts
Normal file
45
e2e/helpers/counterpick-map-pool.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user