diff --git a/app/components/MapPoolSelector.tsx b/app/components/MapPoolSelector.tsx index 14eec3d9f..5a69cc6a2 100644 --- a/app/components/MapPoolSelector.tsx +++ b/app/components/MapPoolSelector.tsx @@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next"; import { Image } from "~/components/Image"; import type { Tables } from "~/db/tables"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; -import { BANNED_MAPS } from "~/features/match-profile/banned-maps"; import { modesShort } from "~/modules/in-game-lists/modes"; import { stageIds } from "~/modules/in-game-lists/stage-ids"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; @@ -31,7 +30,6 @@ export type MapPoolSelectorProps = { footer?: React.ReactNode; /** Enables clear button, template selection, and toggling a whole stage */ allowBulkEdit?: boolean; - hideBanned?: boolean; }; export function MapPoolSelector({ @@ -45,7 +43,6 @@ export function MapPoolSelector({ info, footer, allowBulkEdit = false, - hideBanned = false, }: MapPoolSelectorProps) { const { t } = useTranslation(); @@ -115,7 +112,6 @@ export function MapPoolSelector({ allowBulkEdit={allowBulkEdit} modesToInclude={modesToInclude} preselectedMapPool={preselectedMapPool} - hideBanned={hideBanned} /> {footer} @@ -129,7 +125,6 @@ export type MapPoolStagesProps = { allowBulkEdit?: boolean; modesToInclude?: ModeShort[]; preselectedMapPool?: MapPool; - hideBanned?: boolean; }; export function MapPoolStages({ @@ -138,7 +133,6 @@ export function MapPoolStages({ allowBulkEdit = false, modesToInclude, preselectedMapPool, - hideBanned = false, }: MapPoolStagesProps) { const { t } = useTranslation(["game-misc", "common"]); @@ -254,8 +248,6 @@ export function MapPoolStages({ className={clsx(styles.modeButton, { [styles.selected]: selected, [styles.preselected]: preselected, - invisible: - hideBanned && BANNED_MAPS[mode].includes(stageId), })} onClick={() => handleModeChange?.({ mode, stageId })} type="button" diff --git a/app/db/seed/dev/teams.ts b/app/db/seed/dev/teams.ts index 26476e0d8..51ef38670 100644 --- a/app/db/seed/dev/teams.ts +++ b/app/db/seed/dev/teams.ts @@ -1,4 +1,4 @@ -import { faker } from "../core/faker"; +import { faker, unique } from "../core/faker"; import * as showcaseNames from "../core/showcaseNames"; import * as TeamFactory from "../factories/TeamFactory"; import type { SeededUsers } from "./users"; @@ -67,7 +67,7 @@ export async function seedTeams(users: SeededUsers): Promise { const team = await TeamFactory.create( { - name: i === 1 ? "Team Olive" : showcaseNames.teamName(), + name: i === 1 ? "Team Olive" : unique(() => showcaseNames.teamName()), memberUserIds, }, i === 1 || faker.number.float(1) < 0.3 ? { hasAvatar: true } : undefined, @@ -88,7 +88,7 @@ export async function seedTeams(users: SeededUsers): Promise { const memberUserIds = users.showcaseIds.slice(i * 4, i * 4 + 4); const team = await TeamFactory.create({ - name: showcaseNames.teamName(), + name: unique(() => showcaseNames.teamName()), isMainTeam: false, memberUserIds, }); diff --git a/app/db/seed/dev/tournaments.ts b/app/db/seed/dev/tournaments.ts index 1c173262b..bbdd73fbc 100644 --- a/app/db/seed/dev/tournaments.ts +++ b/app/db/seed/dev/tournaments.ts @@ -1,7 +1,8 @@ import { sub } from "date-fns"; -import type { TournamentSettings } from "~/db/tables-json"; +import type { TeamPickSettings, TournamentSettings } from "~/db/tables-json"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; import { BANNED_MAPS } from "~/features/match-profile/banned-maps"; +import * as TeamPick from "~/features/tournament/core/TeamPick"; import type { TournamentTierNumber } from "~/features/tournament/core/tiering"; import { rankedModesShort } from "~/modules/in-game-lists/modes"; import { stageIds } from "~/modules/in-game-lists/stage-ids"; @@ -21,6 +22,26 @@ import type { SeededTeams } from "./teams"; import type { SeededTrophies } from "./trophies"; import type { SeededUsers } from "./users"; +const SZ_ONLY_TEAM_PICK: TeamPickSettings = { + modes: [{ mode: "SZ", count: 6 }], + pool: "SENDOUQ", +}; +const RANKED_MODES_TEAM_PICK = TeamPick.defaultSettings([...rankedModesShort]); +/** Uneven counts from a custom pool: every SZ stage, half of the legal TC stages. */ +const PADDLING_POOL_TEAM_PICK: TeamPickSettings = { + modes: [ + { mode: "SZ", count: 6 }, + { mode: "TC", count: 3 }, + ], + pool: "CUSTOM", +}; +const PADDLING_POOL_MAP_POOL = [ + ...legalStages("SZ").map((stageId) => ({ mode: "SZ" as const, stageId })), + ...legalStages("TC") + .slice(0, Math.ceil(legalStages("TC").length / 2)) + .map((stageId) => ({ mode: "TC" as const, stageId })), +]; + /** Series the past tournaments are named off; the four in a state worth opening have series of their own. */ const TOURNAMENT_NAME_STEMS = [ { name: "PICNIC", avatarFileName: "picnic.png" }, @@ -244,14 +265,16 @@ async function seedInTheZone({ }; } -/** #2 double elim + underground, AUTO_SZ, ranked — started, nothing reported. N-ZAP is seeded past the byes so he has a match going. */ +/** #2 double elim + underground, team picked SZ ×6 + TC ×3 from a custom pool, ranked — started, nothing reported. N-ZAP is seeded past the byes so he has a match going. */ async function seedPaddlingPool({ users, rosters }: Ctx) { const tournament = await TournamentFactory.create({ name: nameFor("Paddling Pool"), authorId: users.adminId, avatarFileName: "paddling-pool.png", startTimes: [dateToDatabaseTimestamp(hoursAgo(1))], - mapPickingStyle: "AUTO_SZ", + mapPickingStyle: "AUTO", + teamPick: PADDLING_POOL_TEAM_PICK, + mapPoolMaps: PADDLING_POOL_MAP_POOL, bracketProgression: DOUBLE_ELIMINATION_WITH_UNDERGROUND, isRanked: true, }); @@ -266,7 +289,8 @@ async function seedPaddlingPool({ users, rosters }: Ctx) { tournamentId: tournament.id, rosters: teamRosters, isCheckedIn: true, - mapPool: () => counterpickMapPool("AUTO_SZ"), + mapPool: () => + counterpickMapPool(PADDLING_POOL_TEAM_PICK, PADDLING_POOL_MAP_POOL), }); await TournamentFactory.startBracket(tournament.id); @@ -353,6 +377,7 @@ async function seedHistoricalTournaments({ const badgeId = i % 3 === 0 ? badges.ids[i % badges.ids.length] : undefined; const stem = TOURNAMENT_NAME_STEMS[i % TOURNAMENT_NAME_STEMS.length]; const authorId = faker.helpers.arrayElement(users.showcaseIds); + const teamPick = isRecent ? SZ_ONLY_TEAM_PICK : RANKED_MODES_TEAM_PICK; const tournament = await TournamentFactory.create( { @@ -360,8 +385,8 @@ async function seedHistoricalTournaments({ avatarImgId: await seriesLogoImgId(seriesLogoImgIds, stem, authorId), authorId, startTimes: [dateToDatabaseTimestamp(startsAt)], - mapPickingStyle: isRecent ? "AUTO_SZ" : "AUTO_ALL", - mapPoolMaps: isRecent ? undefined : tiebreakerMapPool(), + mapPickingStyle: "AUTO", + teamPick, bracketProgression: progression, teamsPerGroup: 4, isRanked: isRecent, @@ -388,7 +413,7 @@ async function seedHistoricalTournaments({ rosters: teamRosters, isCheckedIn: true, registeredAt: sub(startsAt, { days: 2 }), - mapPool: () => counterpickMapPool(isRecent ? "AUTO_SZ" : "AUTO_ALL"), + mapPool: () => counterpickMapPool(teamPick), }); if (nzapRosterIdx !== null) { @@ -610,10 +635,6 @@ function toSetMapPool() { return mapsPerMode(7); } -function tiebreakerMapPool() { - return mapsPerMode(1); -} - function mapsPerMode(count: number) { return rankedModesShort.flatMap((mode) => legalStages(mode) @@ -622,19 +643,20 @@ function mapsPerMode(count: number) { ); } -function counterpickMapPool(style: "AUTO_SZ" | "AUTO_ALL") { - const pairs = - style === "AUTO_SZ" - ? faker.helpers - .arrayElements(legalStages("SZ"), 6) - .map((stageId) => ({ mode: "SZ" as const, stageId })) - : rankedModesShort.flatMap((mode) => - faker.helpers - .arrayElements(legalStages(mode), 2) - .map((stageId) => ({ mode, stageId })), - ); +/** A team's picks: the configured amount of random stages per mode from the tournament's pool. */ +function counterpickMapPool( + teamPick: TeamPickSettings, + customPool: Array<{ mode: ModeShort; stageId: StageId }> = [], +) { + const pool = TeamPick.effectivePool(teamPick, customPool); - return new MapPool(pairs); + return new MapPool( + teamPick.modes.flatMap(({ mode, count }) => + faker.helpers + .arrayElements(pool.parsed[mode], count) + .map((stageId) => ({ mode, stageId })), + ), + ); } function legalStages(mode: ModeShort): StageId[] { diff --git a/app/db/seed/factories/CalendarEventFactory.ts b/app/db/seed/factories/CalendarEventFactory.ts index 66093a133..071f07a9c 100644 --- a/app/db/seed/factories/CalendarEventFactory.ts +++ b/app/db/seed/factories/CalendarEventFactory.ts @@ -59,7 +59,7 @@ export const { create } = defineFactory({ isFullTournament: false, bracketProgression: null, // only read for events with a tournament of their own - mapPickingStyle: "AUTO_ALL", + mapPickingStyle: "AUTO", }); return { id: eventId }; diff --git a/app/db/tables-json.ts b/app/db/tables-json.ts index 023c828f7..5e5a4a5b3 100644 --- a/app/db/tables-json.ts +++ b/app/db/tables-json.ts @@ -4,6 +4,7 @@ import type { DBBoolean } from "~/db/tables"; import type { CalendarFilters } from "~/features/calendar/calendar-types"; import type { ScrimFilters } from "~/features/scrims/scrims-types"; import type { CustomThemeVar } from "~/features/theme/theme-constants"; +import type { TeamPickPool } from "~/features/tournament/tournament-constants"; import type * as PickBan from "~/features/tournament-bracket/core/PickBan"; import type * as Progression from "~/features/tournament-bracket/core/Progression"; import type { @@ -100,6 +101,14 @@ export interface TournamentSettings { requireSendouQParticipation?: boolean; /** Is this tournament a league? Leagues are played over many weeks, each starting bracket being a division. */ isLeague?: boolean; + /** Team picked map configuration, always set when `Tournament.mapPickingStyle` is "AUTO". */ + teamPick?: TeamPickSettings; +} + +export interface TeamPickSettings { + /** Modes teams pick maps for and how many maps for each, unique modes in `modesShort` order, count ≥ 1. */ + modes: Array<{ mode: ModeShort; count: number }>; + pool: TeamPickPool; } export interface CastedMatchesInfo { @@ -130,6 +139,8 @@ export interface PreparedMaps { export interface TournamentRoundMaps { list?: Array<{ mode: ModeShort; stageId: StageId }> | null; + /** Fixed mode of each slot of a team picked round with a mode pattern, the maps still come from the teams' picks. `count + 2` long for "BAN_2". */ + modes?: ModeShort[] | null; count: number; type: "BEST_OF" | "PLAY_ALL"; pickBan?: PickBan.Type | null; diff --git a/app/db/tables.ts b/app/db/tables.ts index ab96f2c28..6a8475dbe 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -486,7 +486,6 @@ export interface MapPoolMap { calendarEventId: number | null; mode: ModeShort; stageId: StageId; - tieBreakerCalendarEventId: number | null; tournamentTeamId: number | null; } diff --git a/app/features/api-public/routes/tournament-match.$id.ts b/app/features/api-public/routes/tournament-match.$id.ts index 40dfc01ec..c5ecf0208 100644 --- a/app/features/api-public/routes/tournament-match.$id.ts +++ b/app/features/api-public/routes/tournament-match.$id.ts @@ -114,7 +114,8 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { mapPoolByTeamId: (teamId) => mapPools.get(teamId) ?? [], mapPickingStyle: match.mapPickingStyle, maps: match.maps, - tieBreakerMapPool: tournament.ctx.tieBreakerMapPool, + pool: tournament.mapPool, + modesIncluded: tournament.modesIncluded, pickBanEvents, recentlyPlayedMaps: match.mapPickingStyle !== "TO" diff --git a/app/features/api-public/schema.ts b/app/features/api-public/schema.ts index 47d6466ed..6f9cbacae 100644 --- a/app/features/api-public/schema.ts +++ b/app/features/api-public/schema.ts @@ -387,7 +387,7 @@ export type MapListMap = { * One of the following: * - id of the team that picked the map * - "DEFAULT" if it was a default map, something went wrong with the algorithm typically - * - "TIEBREAKER" if it was a tiebreaker map (selected by the TO) + * - "RANDOM" if it was a random map from the tournament's map pool that neither team picked * - "BOTH" both teams picked the map * - "TO" if it was a TO pick (from predefined maplist) * - "COUNTERPICK" if it was a counterpick @@ -396,7 +396,7 @@ export type MapListMap = { source: | number | "DEFAULT" - | "TIEBREAKER" + | "RANDOM" | "BOTH" | "TO" | "COUNTERPICK" diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index bbbe33216..b18bfe004 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -9,13 +9,16 @@ import { sql } from "kysely"; import * as R from "remeda"; import { db } from "~/db/sql"; import type { DB, Tables } from "~/db/tables"; -import type { TournamentSettings } from "~/db/tables-json"; +import type { TeamPickSettings, TournamentSettings } from "~/db/tables-json"; import { EXCLUDED_TAGS } from "~/features/calendar/calendar-constants"; import * as ChatRepository from "~/features/chat/ChatRepository.server"; +import { MapPool } from "~/features/map-list-generator/core/map-pool"; +import * as TeamPick from "~/features/tournament/core/TeamPick"; import * as Progression from "~/features/tournament-bracket/core/Progression"; import * as Series from "~/features/tournament-organization/core/Series"; import { getTentativeTier } from "~/features/tournament-organization/core/tentativeTiers.server"; import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server"; +import { rankedModesShort } from "~/modules/in-game-lists/modes"; import { databaseTimestampNow, databaseTimestampToDate, @@ -67,19 +70,6 @@ const withMapPool = (eb: ExpressionBuilder) => { ).as("mapPool"); }; -const withTieBreakerMapPool = (eb: ExpressionBuilder) => { - return jsonArrayFrom( - eb - .selectFrom("MapPoolMap") - .select(["MapPoolMap.stageId", "MapPoolMap.mode"]) - .whereRef( - "MapPoolMap.tieBreakerCalendarEventId", - "=", - "CalendarEvent.id", - ), - ).as("tieBreakerMapPool"); -}; - const withBadgePrizes = (eb: ExpressionBuilder) => { return jsonArrayFrom( eb @@ -255,7 +245,10 @@ function findAllBetweenTwoTimestampsMapped( : tags.includes("SR") ? ["SR"] : row.mapPickingStyle - ? modesIncluded(row.mapPickingStyle, row.toSetMapPool) + ? modesIncluded( + row.tournamentSettings?.teamPick, + row.toSetMapPool, + ) : null, badges: row.badges, trophy: row.trophy, @@ -290,12 +283,10 @@ export async function findById( id: number, { includeMapPool = false, - includeTieBreakerMapPool = false, includeBadgePrizes = false, includeTrophy = false, }: { includeMapPool?: boolean; - includeTieBreakerMapPool?: boolean; includeBadgePrizes?: boolean; includeTrophy?: boolean; } = {}, @@ -303,7 +294,6 @@ export async function findById( const [firstRow, ...rest] = await db .selectFrom("CalendarEvent") .$if(includeMapPool, (qb) => qb.select(withMapPool)) - .$if(includeTieBreakerMapPool, (qb) => qb.select(withTieBreakerMapPool)) .$if(includeBadgePrizes, (qb) => qb.select(withBadgePrizes)) .$if(includeTrophy, (qb) => qb.select(withTrophy)) .innerJoin( @@ -539,9 +529,12 @@ type CreateArgs = Pick< startTimes: Array; badges: Array; trophyId?: Tables["CalendarEvent"]["trophyId"]; + /** The organizer's map pool: the maps of a "TO" tournament or the custom pool of a team picked one. */ mapPoolMaps?: Array>; isFullTournament: boolean; mapPickingStyle: Tables["Tournament"]["mapPickingStyle"]; + /** Defaults to every ranked mode from the SendouQ pool for an "AUTO" tournament. */ + teamPick?: TeamPickSettings; bracketProgression: TournamentSettings["bracketProgression"] | null; minMembersPerTeam?: number; maxMembersPerTeam?: number; @@ -602,6 +595,7 @@ export async function insert(args: CreateArgs) { roundCount: args.swissRoundCount, } : undefined, + teamPick: teamPickSettings(args), }; tournamentId = ( @@ -657,17 +651,7 @@ export async function insert(args: CreateArgs) { await insertDates({ eventId, startTimes: args.startTimes }, trx); await insertBadges({ eventId, badges: args.badges }, trx); - await upsertMapPool( - { - eventId, - mapPoolMaps: args.mapPoolMaps ?? [], - column: - args.isFullTournament && args.mapPickingStyle !== "TO" - ? "tieBreakerCalendarEventId" - : "calendarEventId", - }, - trx, - ); + await upsertMapPool({ eventId, mapPoolMaps: args.mapPoolMaps ?? [] }, trx); return { eventId, tournamentId }; }); @@ -690,10 +674,7 @@ async function insertSubmittedImage( return result.id; } -type UpdateArgs = Omit< - CreateArgs, - "createTournament" | "mapPickingStyle" | "isFullTournament" -> & { +type UpdateArgs = Omit & { eventId: number; }; export async function update(args: UpdateArgs) { @@ -721,9 +702,9 @@ export async function update(args: UpdateArgs) { .returning("tournamentId") .executeTakeFirstOrThrow(); - const mapPickingStyle = tournamentId - ? await updateTournamentTables(args, trx, tournamentId) - : null; + if (tournamentId) { + await updateTournamentTables(args, trx, tournamentId); + } if (tournamentId) { const { settings: existingSettings } = await trx @@ -755,16 +736,10 @@ export async function update(args: UpdateArgs) { .execute(); await insertBadges({ eventId: args.eventId, badges: args.badges }, trx); - if (!tournamentId || mapPickingStyle === "TO") { - await upsertMapPool( - { - eventId: args.eventId, - mapPoolMaps: args.mapPoolMaps ?? [], - column: "calendarEventId", - }, - trx, - ); - } + await upsertMapPool( + { eventId: args.eventId, mapPoolMaps: args.mapPoolMaps ?? [] }, + trx, + ); }); } @@ -775,13 +750,14 @@ async function updateTournamentTables( ) { invariant(args.bracketProgression, "Expected bracketProgression"); - const existingSettings = ( + const { settings: existingSettings, mapPickingStyle: existingStyle } = await trx .selectFrom("Tournament") - .select("settings") + .select(["settings", "mapPickingStyle"]) .where("id", "=", tournamentId) - .executeTakeFirstOrThrow() - ).settings; + .executeTakeFirstOrThrow(); + + const teamPick = teamPickSettings({ ...args, isFullTournament: true }); const settings: Tables["Tournament"]["settings"] = { bracketProgression: args.bracketProgression, @@ -806,23 +782,49 @@ async function updateTournamentTables( roundCount: args.swissRoundCount, } : undefined, + teamPick, }; const changedFormat = Progression.changedBracketProgressionFormat( existingSettings.bracketProgression, args.bracketProgression, ); + const changedMapPickingStyle = + existingStyle !== args.mapPickingStyle || + !R.isDeepEqual(existingSettings.teamPick ?? null, teamPick ?? null); - const { mapPickingStyle } = await trx + await trx .updateTable("Tournament") .set({ + mapPickingStyle: args.mapPickingStyle, settings: JSON.stringify(settings), rules: args.rules, - preparedMaps: changedFormat ? null : undefined, + preparedMaps: changedFormat || changedMapPickingStyle ? null : undefined, }) .where("id", "=", tournamentId) - .returning("mapPickingStyle") - .executeTakeFirstOrThrow(); + .execute(); + + const existingMapPool = await trx + .selectFrom("MapPoolMap") + .select(["mode", "stageId"]) + .where("calendarEventId", "=", args.eventId) + .execute(); + const changedMapPool = + MapPool.serialize(existingMapPool) !== + MapPool.serialize(args.mapPoolMaps ?? []); + + // the teams' picks were made against the old settings, so they pick again + if (changedMapPickingStyle || changedMapPool) { + await trx + .deleteFrom("MapPoolMap") + .where("tournamentTeamId", "in", (eb) => + eb + .selectFrom("TournamentTeam") + .select("id") + .where("tournamentId", "=", tournamentId), + ) + .execute(); + } if ( changedFormat || @@ -837,8 +839,16 @@ async function updateTournamentTables( .where("tournamentId", "=", tournamentId) .execute(); } +} - return mapPickingStyle; +function teamPickSettings( + args: Pick, +) { + if (!args.isFullTournament || args.mapPickingStyle !== "AUTO") { + return undefined; + } + + return args.teamPick ?? TeamPick.defaultSettings([...rankedModesShort]); } function insertDates( @@ -924,22 +934,15 @@ async function upsertMapPool( { eventId, mapPoolMaps, - column, }: { eventId: number; mapPoolMaps: NonNullable; - column: "tieBreakerCalendarEventId" | "calendarEventId"; }, trx: Transaction, ) { await trx .deleteFrom("MapPoolMap") - .where((eb) => - eb.or([ - eb("calendarEventId", "=", eventId), - eb("tieBreakerCalendarEventId", "=", eventId), - ]), - ) + .where("calendarEventId", "=", eventId) .execute(); await trx @@ -948,7 +951,7 @@ async function upsertMapPool( mapPoolMaps.map((mapPoolMap) => ({ stageId: mapPoolMap.stageId, mode: mapPoolMap.mode, - [column]: eventId, + calendarEventId: eventId, })), ) .execute(); diff --git a/app/features/calendar/actions/calendar.new.server.test.ts b/app/features/calendar/actions/calendar.new.server.test.ts index 08c4b4ec1..97a226b9f 100644 --- a/app/features/calendar/actions/calendar.new.server.test.ts +++ b/app/features/calendar/actions/calendar.new.server.test.ts @@ -5,10 +5,12 @@ import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; import * as TournamentOrganizationFactory from "~/db/seed/factories/TournamentOrganizationFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import * as CalendarRepository from "~/features/calendar/CalendarRepository.server"; +import { MapPool } from "~/features/map-list-generator/core/map-pool"; +import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server"; import { invariant } from "~/utils/invariant"; import { wrappedAction } from "~/utils/Test"; import type { calendarNewSchemaServer } from "../calendar-new-schemas.server"; -import { defaultBracketsFormValues } from "../calendar-progression-form"; +import { calendarNewFormValues } from "../tests/fixtures"; import { action } from "./calendar.new.server"; const editAction = wrappedAction({ @@ -57,37 +59,14 @@ describe("calendar new action: editing an event with badge prizes", () => { eventId: number; organizationId: number; badgeIds: number[]; - }) => ({ - toToolsEnabled: true, - eventToEditId: eventId, - name: "Low Ink (edited)", - description: "", - organizationId: String(organizationId), - rules: "", - date: [], - startTime: addDays(new Date(), 7).toISOString() as never, - bracketUrl: "https://sendou.ink", - discordInviteCode: "", - tags: [], - badges: badgeIds, - trophyId: null, - avatarImgId: null, - regClosesAt: "0" as const, - minMembersPerTeam: "4" as const, - maxMembersPerTeam: undefined, - toToolsMode: "TO" as const, - pool: "", - ...defaultBracketsFormValues(), - isRanked: true, - enableNoScreenToggle: true, - enableSubs: true, - autonomousSubs: true, - requireInGameNames: false, - isInvitational: false, - isTest: false, - isDraft: false, - requireSendouQParticipation: false, - }); + }) => + calendarNewFormValues({ + eventToEditId: eventId, + name: "Low Ink (edited)", + organizationId: String(organizationId), + bracketUrl: "https://sendou.ink", + badges: badgeIds, + }); const badgePrizeIds = async (eventId: number) => ( @@ -136,37 +115,13 @@ describe("calendar new action: bracket URL", () => { const newEventFields = ( overrides: Partial[0]>, - ) => ({ - toToolsEnabled: false, - name: "In The Zone", - description: "", - organizationId: "", - rules: "", - date: [addDays(new Date(), 7).toISOString() as never], - startTime: null, - bracketUrl: "", - discordInviteCode: "", - tags: [], - badges: [], - trophyId: null, - avatarImgId: null, - regClosesAt: "0" as const, - minMembersPerTeam: "4" as const, - maxMembersPerTeam: undefined, - toToolsMode: "TO" as const, - pool: "", - ...defaultBracketsFormValues(), - isRanked: true, - enableNoScreenToggle: true, - enableSubs: true, - autonomousSubs: true, - requireInGameNames: false, - isInvitational: false, - isTest: false, - isDraft: false, - requireSendouQParticipation: false, - ...overrides, - }); + ) => + calendarNewFormValues({ + toToolsEnabled: false, + date: [addDays(new Date(), 7).toISOString() as never], + startTime: null, + ...overrides, + }); test.each([ { @@ -209,3 +164,51 @@ describe("calendar new action: bracket URL", () => { expect(created?.bracketUrl).toBe("https://sendou.ink"); }); }); + +describe("calendar new action: team picked tournament", () => { + beforeEach(async () => { + await UserFactory.createRegular(null, { roles: ["TOURNAMENT_ORGANIZER"] }); + }); + + test("saves the team pick settings and the custom pool", async () => { + const customPool = new MapPool({ + ...MapPool.EMPTY.parsed, + SZ: [1, 2, 3, 4, 5], + TC: [6, 7, 8], + }); + + const res = await editAction( + calendarNewFormValues({ + mapPickingStyle: "AUTO", + teamPickModes: ["TC", "SZ"], + teamPickCounts: [{ mode: "TC", count: 2 }], + teamPickPool: "CUSTOM", + pool: customPool.serialized, + }), + { user: "regular" }, + ); + + expect(res.fieldErrors).toBeUndefined(); + + const location = + res instanceof Response ? res.headers.get("Location") : null; + invariant(location, "expected a redirect to the created event"); + + const created = await CalendarRepository.findById( + Number(location.split("/").at(-1)), + ); + invariant(created?.tournamentId, "expected a tournament to be created"); + + const tournament = await tournamentFromDB(created.tournamentId); + expect(tournament.teamPickSettings).toEqual({ + modes: [ + { mode: "SZ", count: 4 }, + { mode: "TC", count: 2 }, + ], + pool: "CUSTOM", + }); + expect(new MapPool(tournament.ctx.toSetMapPool).serialized).toBe( + customPool.serialized, + ); + }); +}); diff --git a/app/features/calendar/actions/calendar.new.server.ts b/app/features/calendar/actions/calendar.new.server.ts index 996c84d6e..c900089ed 100644 --- a/app/features/calendar/actions/calendar.new.server.ts +++ b/app/features/calendar/actions/calendar.new.server.ts @@ -14,7 +14,6 @@ import { import * as TrophyRepository from "~/features/trophies/TrophyRepository.server"; import { canAccessTrophies } from "~/features/trophies/trophies-utils"; import { parseFormDataWithImages } from "~/form/parse.server"; -import { rankedModesShort } from "~/modules/in-game-lists/modes"; import { requirePermission, requireRole, @@ -31,6 +30,10 @@ import { import { pathnameFromPotentialURL } from "~/utils/strings"; import { calendarEventPage } from "~/utils/urls"; import { CALENDAR_EVENT } from "../calendar-constants"; +import { + customTeamPickPool, + teamPickSettingsFromFormValues, +} from "../calendar-new-schemas"; import { calendarNewSchemaServer } from "../calendar-new-schemas.server"; import { formValuesToInputBrackets } from "../calendar-progression-form"; import { regClosesAtDate } from "../calendar-utils"; @@ -128,8 +131,11 @@ export const action: ActionFunction = async ({ request }) => { // resolved by parseFormDataWithImages from the `image()` field avatarImgId: data.avatarImgId ?? undefined, toToolsEnabled: Number(data.toToolsEnabled), - toToolsMode: - rankedModesShort.find((mode) => mode === data.toToolsMode) ?? null, + mapPickingStyle: data.mapPickingStyle, + teamPick: + isAddingTournament && data.mapPickingStyle === "AUTO" + ? teamPickSettingsFromFormValues(data) + : undefined, bracketProgression: bracketProgressionFromFormData(data), minMembersPerTeam: Number(data.minMembersPerTeam), maxMembersPerTeam: @@ -161,7 +167,13 @@ export const action: ActionFunction = async ({ request }) => { "Bracket progression must be set for tournaments", ); - const deserializedMaps = data.pool ? MapPool.toDbList(data.pool) : undefined; + const deserializedMaps = (() => { + if (!isAddingTournament || data.mapPickingStyle === "TO") { + return data.pool ? MapPool.toDbList(data.pool) : undefined; + } + + return data.teamPickPool === "CUSTOM" ? customTeamPickPool(data) : []; + })(); if (eventToEdit) { if (eventToEdit.tournamentId) { @@ -194,17 +206,11 @@ export const action: ActionFunction = async ({ request }) => { throw redirect(calendarEventPage(eventToEdit.eventId)); } - const mapPickingStyle = () => { - if (data.toToolsMode === "TO") return "TO" as const; - if (data.toToolsMode) return `AUTO_${data.toToolsMode}` as const; - return "AUTO_ALL" as const; - }; const { eventId: createdEventId, tournamentId: createdTournamentId } = await CalendarRepository.insert({ mapPoolMaps: deserializedMaps, isFullTournament: data.toToolsEnabled, - mapPickingStyle: mapPickingStyle(), ...commonArgs, }); diff --git a/app/features/calendar/calendar-new-schemas.test.ts b/app/features/calendar/calendar-new-schemas.test.ts new file mode 100644 index 000000000..7803cf0bf --- /dev/null +++ b/app/features/calendar/calendar-new-schemas.test.ts @@ -0,0 +1,172 @@ +import * as v from "valibot"; +import { describe, expect, test } from "vitest"; +import type { TeamPickSettings } from "~/db/tables-json"; +import { MapPool } from "~/features/map-list-generator/core/map-pool"; +import type { StageId } from "~/modules/in-game-lists/types"; +import { + customTeamPickPool, + teamPickSettingsFromFormValues, +} from "./calendar-new-schemas"; +import { calendarNewSchemaServer } from "./calendar-new-schemas.server"; +import { + type CalendarNewFormValues, + calendarNewFormValues, +} from "./tests/fixtures"; + +const stages = (count: number) => + Array.from({ length: count }, (_, i) => i as StageId); + +/** Serialized custom pool with the given amount of stages in Splat Zones and Tower Control. */ +const customPool = (szStages: number, tcStages: number) => + new MapPool({ + ...MapPool.EMPTY.parsed, + SZ: stages(szStages), + TC: stages(tcStages), + }).serialized; + +const teamPickValues = (overrides: Partial = {}) => + calendarNewFormValues({ + mapPickingStyle: "AUTO", + teamPickModes: ["SZ", "TC"], + teamPickCounts: [ + { mode: "SZ", count: 2 }, + { mode: "TC", count: 2 }, + ], + teamPickPool: "SENDOUQ", + ...overrides, + }); + +const issuesOf = (values: CalendarNewFormValues) => { + const result = v.safeParse(calendarNewSchemaServer, values); + + return (result.issues ?? []).map((issue) => ({ + path: issue.path?.map((item) => String(item.key)).join("."), + message: issue.message, + })); +}; + +describe("calendarNewSchemaServer team pick", () => { + test("accepts team pick settings the pool can serve", () => { + expect(issuesOf(teamPickValues())).toEqual([]); + }); + + test("rejects a team picked tournament without modes", () => { + expect( + issuesOf(teamPickValues({ teamPickModes: [], teamPickCounts: [] })), + ).toContainEqual({ + path: "teamPickModes", + message: "forms:errors.teamPick.noModes", + }); + }); + + test("rejects a custom pool with fewer stages than picks plus one in a mode", () => { + expect( + issuesOf( + teamPickValues({ teamPickPool: "CUSTOM", pool: customPool(3, 2) }), + ), + ).toContainEqual({ + path: "pool", + message: "forms:errors.teamPick.poolTooSmall", + }); + }); + + test("accepts a custom pool with one more stage than picks in every mode", () => { + expect( + issuesOf( + teamPickValues({ teamPickPool: "CUSTOM", pool: customPool(3, 3) }), + ), + ).toEqual([]); + }); + + test.each([ + { why: "zero from the SendouQ pool", pool: "SENDOUQ", count: 0 }, + { why: "above the SendouQ pool's size", pool: "SENDOUQ", count: 100 }, + { why: "zero from a custom pool", pool: "CUSTOM", count: 0 }, + { why: "negative from a custom pool", pool: "CUSTOM", count: -1 }, + ] satisfies Array<{ + why: string; + pool: TeamPickSettings["pool"]; + count: number; + }>)("rejects a count out of range ($why)", ({ pool, count }) => { + expect( + issuesOf( + teamPickValues({ + teamPickPool: pool, + teamPickCounts: [ + { mode: "SZ", count }, + { mode: "TC", count: 2 }, + ], + pool: pool === "CUSTOM" ? customPool(3, 3) : "", + }), + ), + ).toContainEqual({ + path: "teamPickCounts", + message: "forms:errors.teamPick.countOutOfRange", + }); + }); + + test("ignores the team pick fields of an organizer picked tournament", () => { + expect( + issuesOf( + calendarNewFormValues({ + mapPickingStyle: "TO", + teamPickModes: [], + teamPickCounts: [{ mode: "SZ", count: 0 }], + }), + ), + ).toEqual([]); + }); +}); + +describe("teamPickSettingsFromFormValues", () => { + test("sorts the modes in the in-game order keeping their counts", () => { + expect( + teamPickSettingsFromFormValues({ + teamPickModes: ["CB", "SZ"], + teamPickCounts: [ + { mode: "CB", count: 3 }, + { mode: "SZ", count: 5 }, + ], + teamPickPool: "ALL", + }), + ).toEqual({ + modes: [ + { mode: "SZ", count: 5 }, + { mode: "CB", count: 3 }, + ], + pool: "ALL", + }); + }); + + test("fills in the default count for a picked mode without one", () => { + expect( + teamPickSettingsFromFormValues({ + teamPickModes: ["SZ", "TC", "RM"], + teamPickCounts: [{ mode: "TC", count: 1 }], + teamPickPool: "SENDOUQ", + }).modes, + ).toEqual([ + { mode: "SZ", count: 3 }, + { mode: "TC", count: 1 }, + { mode: "RM", count: 3 }, + ]); + }); +}); + +describe("customTeamPickPool", () => { + test("keeps only the maps of the picked modes", () => { + expect( + customTeamPickPool({ + teamPickModes: ["SZ"], + pool: customPool(2, 2), + }), + ).toEqual([ + { mode: "SZ", stageId: 0 }, + { mode: "SZ", stageId: 1 }, + ]); + }); + + test("is empty without a pool", () => { + expect(customTeamPickPool({ teamPickModes: ["SZ"], pool: "" })).toEqual([]); + }); +}); diff --git a/app/features/calendar/calendar-new-schemas.ts b/app/features/calendar/calendar-new-schemas.ts index 93a89754a..bf686ffc9 100644 --- a/app/features/calendar/calendar-new-schemas.ts +++ b/app/features/calendar/calendar-new-schemas.ts @@ -1,5 +1,8 @@ import * as v from "valibot"; +import type { TeamPickSettings } from "~/db/tables-json"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; +import * as TeamPick from "~/features/tournament/core/TeamPick"; +import { TEAM_PICK_POOLS } from "~/features/tournament/tournament-constants"; import { array, badges, @@ -11,6 +14,7 @@ import { idConstantOptional, image, numberFieldOptional, + radioGroup, select, selectDynamicOptional, textAreaOptional, @@ -18,8 +22,9 @@ import { textFieldOptional, toggle, } from "~/form/fields"; -import { rankedModesShort } from "~/modules/in-game-lists/modes"; -import { id, type ValidationCtx } from "~/utils/schema"; +import { modesShort } from "~/modules/in-game-lists/modes"; +import type { ModeShort } from "~/modules/in-game-lists/types"; +import { id, modeShort, type ValidationCtx } from "~/utils/schema"; import { CALENDAR_EVENT, REG_CLOSES_AT_OPTIONS } from "./calendar-constants"; import { bracketsFormField, @@ -36,18 +41,43 @@ const calendarEventDateField = datetime({ }); // extracted so the literal item values don't widen to `string` in the object's inferred value type -const toToolsModeField = select({ +const mapPickingStyleField = radioGroup({ label: "labels.mapPickingStyle", items: [ - { value: "ALL", label: "options.toToolsMode.ALL" }, - { value: "SZ", label: "options.toToolsMode.SZ" }, - { value: "TC", label: "options.toToolsMode.TC" }, - { value: "RM", label: "options.toToolsMode.RM" }, - { value: "CB", label: "options.toToolsMode.CB" }, - { value: "TO", label: "options.toToolsMode.TO" }, + { value: "TO", label: "options.mapPickingStyle.TO" }, + { value: "AUTO", label: "options.mapPickingStyle.AUTO" }, ], }); +const teamPickModesField = checkboxGroup({ + label: "labels.teamPickModes", + items: modesShort.map((mode) => ({ + value: mode, + label: `modes.${mode}` as const, + })), +}); + +const teamPickPoolField = radioGroup({ + label: "labels.teamPickPool", + items: TEAM_PICK_POOLS.map((pool) => ({ + value: pool, + label: `options.teamPickPool.${pool}` as const, + })), +}); + +/** How many maps a team picks per mode, one entry per picked mode. */ +export type TeamPickCountsFormValue = Array<{ mode: ModeShort; count: number }>; + +const teamPickCountsField = customField( + { initialValue: [] as TeamPickCountsFormValue }, + v.array( + v.object({ + mode: modeShort, + count: v.pipe(v.number(), v.integer()), + }), + ), +); + export const calendarNewBaseSchema = v.object({ // discriminates between a calendar event and a tournament; seeded from the loader, no visible control toToolsEnabled: hidden(v.boolean(), false), @@ -124,7 +154,11 @@ export const calendarNewBaseSchema = v.object({ label: "labels.maxTeamSize", bottomText: "bottomTexts.maxTeamSize", }), - toToolsMode: toToolsModeField, + mapPickingStyle: mapPickingStyleField, + teamPickModes: teamPickModesField, + teamPickCounts: teamPickCountsField, + teamPickPool: teamPickPoolField, + // organizer's map pool: of a calendar event, a "TO" tournament or the custom pool of a team picked one pool: customField({ initialValue: "" }, v.optional(v.string())), // only rendered (and validated) for tournaments, calendar events keep the empty initial values brackets: bracketsFormField, @@ -207,19 +241,8 @@ export function calendarNewSyncRefine( } } - // "Prepicked by teams - All modes" requires one tiebreaker map per ranked mode - if (data.toToolsEnabled && data.toToolsMode === "ALL") { - const maps = data.pool ? MapPool.toDbList(data.pool) : []; - const isValid = - maps.length === rankedModesShort.length && - rankedModesShort.every((mode) => maps.some((map) => map.mode === mode)); - - if (!isValid) { - ctx.addIssue({ - path: ["pool"], - message: "forms:errors.allModePool", - }); - } + if (data.toToolsEnabled && data.mapPickingStyle === "AUTO") { + validateTeamPick(data, ctx); } if (data.trophyId && data.badges.length > 0) { @@ -241,3 +264,75 @@ export function calendarNewSyncRefine( }); } } + +function validateTeamPick( + data: Pick< + v.InferOutput, + "teamPickModes" | "teamPickCounts" | "teamPickPool" | "pool" + >, + ctx: ValidationCtx, +) { + if (data.teamPickModes.length === 0) { + ctx.addIssue({ + path: ["teamPickModes"], + message: "forms:errors.teamPick.noModes", + }); + return; + } + + const teamPick = teamPickSettingsFromFormValues(data); + const pool = TeamPick.effectivePool(teamPick, customTeamPickPool(data)); + + if ( + teamPick.pool === "CUSTOM" && + TeamPick.poolShortfalls(teamPick, pool).length > 0 + ) { + ctx.addIssue({ + path: ["pool"], + message: "forms:errors.teamPick.poolTooSmall", + }); + return; + } + + const countOutOfRange = teamPick.modes.some( + ({ mode, count }) => count < 1 || count > TeamPick.maxCount(pool, mode), + ); + if (countOutOfRange) { + ctx.addIssue({ + path: ["teamPickCounts"], + message: "forms:errors.teamPick.countOutOfRange", + }); + } +} + +/** Team pick settings the form values describe, a picked mode without a count gets the default one. */ +export function teamPickSettingsFromFormValues(data: { + teamPickModes: ModeShort[]; + teamPickCounts: TeamPickCountsFormValue; + teamPickPool: TeamPickSettings["pool"]; +}): TeamPickSettings { + const modes = TeamPick.sortModes(data.teamPickModes); + const defaultCount = TeamPick.defaultCount(modes.length); + + return { + modes: modes.map((mode) => ({ + mode, + count: + data.teamPickCounts.find((count) => count.mode === mode)?.count ?? + defaultCount, + })), + pool: data.teamPickPool, + }; +} + +/** The custom pool of a team picked tournament, limited to the picked modes. */ +export function customTeamPickPool(data: { + teamPickModes: ModeShort[]; + pool?: string; +}) { + if (!data.pool) return []; + + return MapPool.toDbList(data.pool).filter((map) => + data.teamPickModes.includes(map.mode), + ); +} diff --git a/app/features/calendar/calendar-new.module.css b/app/features/calendar/calendar-new.module.css index 336dfe9b7..73898a1a4 100644 --- a/app/features/calendar/calendar-new.module.css +++ b/app/features/calendar/calendar-new.module.css @@ -3,3 +3,7 @@ border: var(--border-style); border-radius: var(--radius-field); } + +.countInput { + width: 4.5rem; +} diff --git a/app/features/calendar/loaders/calendar.new.server.ts b/app/features/calendar/loaders/calendar.new.server.ts index c18e4792b..f884219df 100644 --- a/app/features/calendar/loaders/calendar.new.server.ts +++ b/app/features/calendar/loaders/calendar.new.server.ts @@ -27,7 +27,6 @@ export const loader = async ({ url }: LoaderFunctionArgs) => { ? undefined : await CalendarRepository.findById(id, { includeMapPool: true, - includeTieBreakerMapPool: true, includeBadgePrizes: true, includeTrophy: true, }); @@ -35,12 +34,21 @@ export const loader = async ({ url }: LoaderFunctionArgs) => { if (!event) return; if (!event?.tournamentId) - return { ...event, tournament: null, rules: null }; + return { + ...event, + tournament: null, + rules: null, + teamsHavePickedMaps: false, + }; + + const eventTournament = await tournamentData(event.tournamentId); return { ...event, - tournament: await tournamentData(event.tournamentId), + tournament: eventTournament, rules: await TournamentRepository.findRulesById(event.tournamentId), + teamsHavePickedMaps: + eventTournament?.ctx.teams.some((team) => team.hasMapPool) ?? false, }; }; diff --git a/app/features/calendar/routes/calendar.new.tsx b/app/features/calendar/routes/calendar.new.tsx index 9284782a1..ac38a6ee6 100644 --- a/app/features/calendar/routes/calendar.new.tsx +++ b/app/features/calendar/routes/calendar.new.tsx @@ -3,23 +3,29 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; import { Form, Link, useLoaderData } from "react-router"; -import type { AlertVariation } from "~/components/Alert"; import { Alert } from "~/components/Alert"; import { Divider } from "~/components/Divider"; import { SendouButton } from "~/components/elements/Button"; import { FormMessage } from "~/components/FormMessage"; +import { ModeImage } from "~/components/Image"; +import { Label } from "~/components/Label"; import { Main } from "~/components/Main"; import { MapPoolSelector } from "~/components/MapPoolSelector"; import { SubmitButton } from "~/components/SubmitButton"; -import type { Tables } from "~/db/tables"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; +import * as TeamPick from "~/features/tournament/core/TeamPick"; +import type { + TeamPickPool, + TournamentMapPickingStyle, +} from "~/features/tournament/tournament-constants"; import { Trophy } from "~/features/trophies/components/Trophy"; import { type CustomFieldRenderProps, FormField } from "~/form/FormField"; import { existingImage } from "~/form/image-field"; import { SendouForm, useFormFieldContext } from "~/form/SendouForm"; import { errorMessageId } from "~/form/utils"; import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; -import type { RankedModeShort } from "~/modules/in-game-lists/types"; +import { rankedModesShort } from "~/modules/in-game-lists/modes"; +import type { ModeShort } from "~/modules/in-game-lists/types"; import { useHasRole } from "~/modules/permissions/hooks"; import { databaseTimestampToDate, getDateAtNextFullHour } from "~/utils/dates"; import { metaTags } from "~/utils/remix"; @@ -28,7 +34,12 @@ import { CREATING_TOURNAMENT_DOC_LINK, FAQ_PAGE } from "~/utils/urls"; import { action } from "../actions/calendar.new.server"; import type { RegClosesAtOption } from "../calendar-constants"; import styles from "../calendar-new.module.css"; -import { calendarNewBaseSchema } from "../calendar-new-schemas"; +import { + calendarNewBaseSchema, + customTeamPickPool, + type TeamPickCountsFormValue, + teamPickSettingsFromFormValues, +} from "../calendar-new-schemas"; import { defaultBracketsFormValues, progressionToFormValues, @@ -56,18 +67,6 @@ export const handle: SendouRouteHandle = { i18n: ["calendar", "game-misc", "tournament"], }; -const mapPickingStyleToShort: Record< - Tables["Tournament"]["mapPickingStyle"], - "ALL" | "TO" | RankedModeShort -> = { - TO: "TO", - AUTO_ALL: "ALL", - AUTO_SZ: "SZ", - AUTO_TC: "TC", - AUTO_RM: "RM", - AUTO_CB: "CB", -}; - const useBaseEvent = () => { const { eventToEdit, eventToCopy } = useLoaderData(); @@ -155,22 +154,14 @@ function useDefaultValues() { }) : "0"; - const toToolsMode = baseEvent?.mapPickingStyle - ? mapPickingStyleToShort[baseEvent.mapPickingStyle] - : "ALL"; + const mapPickingStyle: TournamentMapPickingStyle = + baseEvent?.mapPickingStyle ?? "AUTO"; + const teamPick = + settings?.teamPick ?? TeamPick.defaultSettings([...rankedModesShort]); - const pool = (() => { - if (!baseEvent) return ""; - if (!data.isAddingTournament || toToolsMode === "TO") { - return baseEvent.mapPool ? new MapPool(baseEvent.mapPool).serialized : ""; - } - if (toToolsMode === "ALL") { - return baseEvent.tieBreakerMapPool - ? new MapPool(baseEvent.tieBreakerMapPool).serialized - : ""; - } - return ""; - })(); + const pool = baseEvent?.mapPool + ? new MapPool(baseEvent.mapPool).serialized + : ""; const bracketProgressionValues = settings?.bracketProgression ? progressionToFormValues(settings.bracketProgression) @@ -217,7 +208,10 @@ function useDefaultValues() { | "3" | "4", maxMembersPerTeam: settings?.maxMembersPerTeam ?? undefined, - toToolsMode, + mapPickingStyle, + teamPickModes: TeamPick.pickedModes(teamPick), + teamPickCounts: teamPick.modes, + teamPickPool: teamPick.pool, pool, brackets: bracketProgressionValues.brackets, progression: bracketProgressionValues.progression, @@ -461,29 +455,15 @@ function DraftField() { } function MapsSection({ isTournament }: { isTournament: boolean }) { + const { t } = useTranslation(["forms"]); const { values, setValue } = useFormFieldContext(); const data = useLoaderData(); - const mode = values.toToolsMode as "ALL" | "TO" | RankedModeShort; if (!isTournament) { return ; } - const isEditing = Boolean(data.eventToEdit); - - // can't change map picking style after creation - if (isEditing) { - if (mode !== "TO") return null; - - return ( -
- - Tournament maps - - -
- ); - } + const mapPickingStyle = values.mapPickingStyle as TournamentMapPickingStyle; return (
@@ -491,17 +471,241 @@ function MapsSection({ isTournament }: { isTournament: boolean }) { Tournament maps {/* reset the (polymorphic) pool when switching map picking style so a - previous mode's maps don't leak into the new one */} + previous style's maps don't leak into the new one */} setValue("pool", "")} /> - {mode === "ALL" ? : null} - {mode === "TO" ? : null} + {mapPickingStyle === "AUTO" ? ( + + ) : ( + + )} + {data.eventToEdit?.teamsHavePickedMaps ? ( +
+ {t("forms:bottomTexts.teamPickReset")} +
+ ) : null}
); } +function TeamPickFields() { + const { values, setValue } = useFormFieldContext(); + // counts the organizer set by hand keep their value when the mode set changes + const [touchedModes, setTouchedModes] = React.useState< + ReadonlySet + >(new Set()); + + const teamPickPool = values.teamPickPool as TeamPickPool; + const pickedModes = TeamPick.sortModes(values.teamPickModes as ModeShort[]); + + const handleModesChange = (newValue: unknown) => { + const modes = TeamPick.sortModes(newValue as ModeShort[]); + const counts = values.teamPickCounts as TeamPickCountsFormValue; + const defaultCount = TeamPick.defaultCount(modes.length); + + setValue( + "teamPickCounts", + modes.map((mode) => ({ + mode, + count: touchedModes.has(mode) + ? (counts.find((count) => count.mode === mode)?.count ?? defaultCount) + : defaultCount, + })), + ); + + if (typeof values.pool === "string" && values.pool) { + setValue( + "pool", + new MapPool( + customTeamPickPool({ teamPickModes: modes, pool: values.pool }), + ).serialized, + ); + } + }; + + return ( + <> + + + {({ value, onChange, error }: CustomFieldRenderProps) => ( + { + setTouchedModes(new Set([...touchedModes, touchedMode])); + onChange(newValue); + }} + error={error} + /> + )} + + setValue("pool", "")} + /> + {teamPickPool === "CUSTOM" ? ( + + ) : null} + + ); +} + +function TeamPickCountInputs({ + value, + onChange, + error, +}: { + value: TeamPickCountsFormValue; + onChange: (value: TeamPickCountsFormValue, touchedMode: ModeShort) => void; + error?: string; +}) { + const { t } = useTranslation(["forms", "game-misc"]); + const { values } = useFormFieldContext(); + const id = React.useId(); + + const pickedModes = TeamPick.sortModes(values.teamPickModes as ModeShort[]); + if (pickedModes.length === 0) return null; + + const pool = TeamPick.effectivePool( + teamPickSettingsFromFormValues({ + teamPickModes: pickedModes, + teamPickCounts: value, + teamPickPool: values.teamPickPool as TeamPickPool, + }), + customTeamPickPool({ + teamPickModes: pickedModes, + pool: values.pool as string | undefined, + }), + ); + + return ( +
+ +
+ {pickedModes.map((mode) => { + const count = value.find((c) => c.mode === mode)?.count ?? ""; + + return ( +
+ + + onChange( + pickedModes.map((m) => ({ + mode: m, + count: + m === mode + ? Number(e.target.value) + : (value.find((c) => c.mode === m)?.count ?? 1), + })), + mode, + ) + } + /> +
+ ); + })} +
+ + {t("forms:bottomTexts.teamPickCounts")} + + {error ? ( + + {t(error as never)} + + ) : null} +
+ ); +} + +function CustomTeamPickPoolField({ + pickedModes, +}: { + pickedModes: ModeShort[]; +}) { + const { t } = useTranslation(["common", "calendar", "game-misc"]); + const { values } = useFormFieldContext(); + + return ( + + {({ value, onChange, error }: CustomFieldRenderProps) => { + const mapPool = new MapPool( + customTeamPickPool({ + teamPickModes: pickedModes, + pool: value as string | undefined, + }), + ); + const teamPick = teamPickSettingsFromFormValues({ + teamPickModes: pickedModes, + teamPickCounts: values.teamPickCounts as TeamPickCountsFormValue, + teamPickPool: "CUSTOM", + }); + const shortfalls = TeamPick.poolShortfalls(teamPick, mapPool); + + return ( + <> + + onChange( + new MapPool( + customTeamPickPool({ + teamPickModes: pickedModes, + pool: newPool.serialized, + }), + ).serialized, + ) + } + allowBulkEdit + info={ +
+ + {shortfalls.length === 0 + ? t("calendar:forms.teamPick.poolOk") + : shortfalls + .map(({ mode, required, has }) => + t("calendar:forms.teamPick.poolShortfall", { + mode: t(`game-misc:MODE_SHORT_${mode}`), + required, + has, + }), + ) + .join(", ")} + +
+ } + /> + {error ? ( + + {t(error as never)} + + ) : null} + + ); + }} +
+ ); +} + function CalendarMapPoolField() { const { t } = useTranslation(["common"]); const baseEvent = useBaseEvent(); @@ -569,38 +773,6 @@ function TournamentMapPoolField() { ); } -function TiebreakerMapPoolField() { - const { t } = useTranslation(["common"]); - - return ( - - {({ value, onChange, error }: CustomFieldRenderProps) => { - const mapPool = value ? new MapPool(value as string) : MapPool.EMPTY; - const status = validateTiebreakerMapPool(mapPool); - - return ( - <> - onChange(newPool.serialized)} - info={} - /> - {error ? ( - - {t(error as never)} - - ) : null} - - ); - }} - - ); -} - function BracketProgressionField() { const { values } = useFormFieldContext(); @@ -615,55 +787,3 @@ function BracketProgressionField() { ); } - -type CounterPickValidationStatus = - | "PICKING" - | "VALID" - | "NOT_ONE_MAP_PER_MODE" - | "MAP_REPEATED" - | "MODE_REPEATED"; - -function validateTiebreakerMapPool( - mapPool: MapPool, -): CounterPickValidationStatus { - if (mapPool.stages.length !== new Set(mapPool.stages).size) { - return "MAP_REPEATED"; - } - if ( - mapPool.parsed.SZ.length > 1 || - mapPool.parsed.TC.length > 1 || - mapPool.parsed.RM.length > 1 || - mapPool.parsed.CB.length > 1 - ) { - return "MODE_REPEATED"; - } - if ( - mapPool.parsed.SZ.length < 1 || - mapPool.parsed.TC.length < 1 || - mapPool.parsed.RM.length < 1 || - mapPool.parsed.CB.length < 1 - ) { - return "PICKING"; - } - - return "VALID"; -} - -function MapPoolValidationStatusMessage({ - status, -}: { - status: CounterPickValidationStatus; -}) { - const { t } = useTranslation(["common"]); - - const alertVariation: AlertVariation = - status === "VALID" ? "SUCCESS" : status === "PICKING" ? "INFO" : "WARNING"; - - return ( -
- - {t(`common:maps.validation.${status}`)} - -
- ); -} diff --git a/app/features/calendar/tests/fixtures.ts b/app/features/calendar/tests/fixtures.ts new file mode 100644 index 000000000..dc7d6c87e --- /dev/null +++ b/app/features/calendar/tests/fixtures.ts @@ -0,0 +1,48 @@ +import { addDays } from "date-fns"; +import type * as v from "valibot"; +import type { calendarNewSchemaServer } from "../calendar-new-schemas.server"; +import { defaultBracketsFormValues } from "../calendar-progression-form"; + +export type CalendarNewFormValues = v.InferOutput< + typeof calendarNewSchemaServer +>; + +/** Every field of the calendar new form filled in for a tournament starting in a week, override what the test is about. */ +export function calendarNewFormValues( + overrides: Partial = {}, +): CalendarNewFormValues { + return { + toToolsEnabled: true, + name: "In The Zone", + description: "", + organizationId: "", + rules: "", + date: [], + startTime: addDays(new Date(), 7).toISOString() as never, + bracketUrl: "", + discordInviteCode: "", + tags: [], + badges: [], + trophyId: null, + avatarImgId: null, + regClosesAt: "0", + minMembersPerTeam: "4", + maxMembersPerTeam: undefined, + mapPickingStyle: "TO", + teamPickModes: [], + teamPickCounts: [], + teamPickPool: "SENDOUQ", + pool: "", + ...defaultBracketsFormValues(), + isRanked: true, + enableNoScreenToggle: true, + enableSubs: true, + autonomousSubs: true, + requireInGameNames: false, + isInvitational: false, + isTest: false, + isDraft: false, + requireSendouQParticipation: false, + ...overrides, + }; +} diff --git a/app/features/settings/components/MapModePreferencesField.tsx b/app/features/settings/components/MapModePreferencesField.tsx index 4de71b588..42d1685db 100644 --- a/app/features/settings/components/MapModePreferencesField.tsx +++ b/app/features/settings/components/MapModePreferencesField.tsx @@ -1,7 +1,11 @@ import * as React from "react"; +import { useTranslation } from "react-i18next"; import { ModeImage } from "~/components/Image"; import type { Preference, UserMapModePreferences } from "~/db/tables-json"; -import { BANNED_MAPS } from "~/features/match-profile/banned-maps"; +import { + BANNED_MAPS, + SENDOUQ_MAP_POOL, +} from "~/features/match-profile/banned-maps"; import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE } from "~/features/match-profile/match-profile-constants"; import { modesShort } from "~/modules/in-game-lists/modes"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; @@ -29,6 +33,7 @@ export function MapModePreferencesField({ value: UserMapModePreferences; onChange: (value: UserMapModePreferences) => void; }) { + const { t } = useTranslation(["common"]); const handleModePreferenceChange = ({ mode, preference, @@ -93,6 +98,8 @@ export function MapModePreferencesField({ modeTabs={pickableModes} onModeChange={setSelectedMode} amountToPick={AMOUNT_OF_MAPS_IN_POOL_PER_MODE} + allowedStages={SENDOUQ_MAP_POOL.parsed[activeMode]} + unavailableLabel={t("common:maps.banned")} pool={value.pool.find((p) => p.mode === activeMode)?.stages ?? []} onChange={(stages) => handlePoolChange(activeMode, stages)} /> diff --git a/app/features/settings/components/ModeMapPoolPicker.tsx b/app/features/settings/components/ModeMapPoolPicker.tsx index 38e268dbc..7e88eea13 100644 --- a/app/features/settings/components/ModeMapPoolPicker.tsx +++ b/app/features/settings/components/ModeMapPoolPicker.tsx @@ -4,7 +4,6 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import { Divider } from "~/components/Divider"; import { ModeImage } from "~/components/Image"; -import { BANNED_MAPS } from "~/features/match-profile/banned-maps"; import { shortStageName, stageIds } from "~/modules/in-game-lists/stage-ids"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import { nullFilledArray } from "~/utils/arrays"; @@ -15,7 +14,8 @@ export function ModeMapPoolPicker({ mode, amountToPick, pool, - tiebreaker, + allowedStages, + unavailableLabel, onChange, modeTabs, onModeChange, @@ -24,7 +24,9 @@ export function ModeMapPoolPicker({ mode: ModeShort; amountToPick: number; pool: StageId[]; - tiebreaker?: StageId; + /** Stages that can be picked. The rest are shown greyed out with `unavailableLabel`, or not at all without one. */ + allowedStages: readonly StageId[]; + unavailableLabel?: string; onChange: (stages: StageId[]) => void; /** When provided, the divider becomes a tab switcher between these modes. */ modeTabs?: ModeShort[]; @@ -98,14 +100,13 @@ export function ModeMapPoolPicker({
{stageIds.map((stageId) => { - const isTiebreaker = tiebreaker === stageId; - const banned = BANNED_MAPS[mode].includes(stageId); + const unavailable = !allowedStages.includes(stageId); + if (unavailable && !unavailableLabel) return null; const selected = stages.includes(stageId); const onClick = () => { if (disabled) return; - if (isTiebreaker) return; - if (banned) return; + if (unavailable) return; if (selected) return handlePickedStageClick(stageId); handleUnpickedStageClick(stageId); @@ -117,8 +118,8 @@ export function ModeMapPoolPicker({ stageId={stageId} onClick={onClick} selected={selected} - banned={banned} - tiebreaker={isTiebreaker} + unavailable={unavailable} + unavailableLabel={unavailableLabel} wiggle={wigglingStageId === stageId} onWiggleEnd={() => setWigglingStageId(null)} disabled={disabled} @@ -147,8 +148,8 @@ function MapButton({ stageId, onClick, selected, - banned, - tiebreaker, + unavailable, + unavailableLabel, wiggle, onWiggleEnd, disabled, @@ -157,8 +158,8 @@ function MapButton({ stageId: StageId; onClick: () => void; selected?: boolean; - banned?: boolean; - tiebreaker?: boolean; + unavailable?: boolean; + unavailableLabel?: string; wiggle?: boolean; onWiggleEnd?: () => void; disabled?: boolean; @@ -173,12 +174,12 @@ function MapButton({
@@ -588,7 +593,8 @@ export function BracketMapListDialog({ setMaps( generateTournamentRoundMaplist({ mapCounts: newMapCounts, - pool: tournament.ctx.toSetMapPool, + pool: tournament.mapPool.dbList, + teamsPickMaps: tournament.teamsPrePickMaps, rounds, type: bracket.type, roundsWithPickBan, @@ -631,7 +637,8 @@ export function BracketMapListDialog({ const newMap = generateTournamentRoundMaplist({ mapCounts: newMapCounts, - pool: tournament.ctx.toSetMapPool, + pool: tournament.mapPool.dbList, + teamsPickMaps: tournament.teamsPrePickMaps, rounds, type: bracket.type, roundsWithPickBan, @@ -651,7 +658,8 @@ export function BracketMapListDialog({ setMaps( generateTournamentRoundMaplist({ mapCounts, - pool: tournament.ctx.toSetMapPool, + pool: tournament.mapPool.dbList, + teamsPickMaps: tournament.teamsPrePickMaps, rounds, type: bracket.type, roundsWithPickBan: newRoundsWithPickBan, @@ -674,7 +682,8 @@ export function BracketMapListDialog({ const newMap = generateTournamentRoundMaplist({ mapCounts, - pool: tournament.ctx.toSetMapPool, + pool: tournament.mapPool.dbList, + teamsPickMaps: tournament.teamsPrePickMaps, rounds, type: bracket.type, roundsWithPickBan: newRoundsWithPickBan, @@ -974,8 +983,6 @@ function RoundMapList({ link?: () => void; hoveredMap: string | null; }) { - const tournament = useTournament(); - const minCount = TOURNAMENT.AVAILABLE_BEST_OF[0]; const maxCount = TOURNAMENT.AVAILABLE_BEST_OF.at(-1)!; @@ -1044,7 +1051,6 @@ function RoundMapList({ key={i} number={i + 1} isCounterpicks={false} - isTiebreaker={false} isCustomFlow /> )) @@ -1072,20 +1078,34 @@ function RoundMapList({ } const isTeamsPick = !maps.list && i === 0; - const isLast = - i === - (maps.pickBan === "BAN_2" ? maps.count + 2 : maps.count) - 1; + const isCounterpicks = + !isTeamsPick && maps.pickBan === "COUNTERPICK"; + const mode = maps.modes?.[i]; + + if (mode) { + return ( + { + onRoundMapListChange({ + ...maps, + modes: maps.modes?.map((m, j) => + i === j ? newMode : m, + ), + }); + }} + /> + ); + } return ( ); })} @@ -1226,17 +1246,68 @@ export function mapSearchFilter( return contains(stageName, restQuery); } +/** A team picked slot: the mode is fixed here, the map comes from the teams' picks at match time. */ +function ModeListRow({ + mode, + number, + isCounterpicks, + onModeChange, +}: { + mode: ModeShort; + number: number; + isCounterpicks: boolean; + onModeChange: (mode: ModeShort) => void; +}) { + const { t } = useTranslation(["game-misc", "tournament"]); + const tournament = useTournament(); + + const items = tournament.modesIncluded.map((m) => ({ + id: m, + name: t(`game-misc:MODE_LONG_${m}`), + })); + + return ( +
  • + {number}. + { + if (key === null) return; + onModeChange(String(key) as ModeShort); + }} + className={styles.mapRowSelect} + > + {(item) => ( + +
    + +
    ?
    + + {isCounterpicks + ? t("tournament:pickInfo.counterpick") + : t("tournament:mapList.teamsPick")} + +
    +
    + )} +
    +
  • + ); +} + function MysteryRow({ number, isCounterpicks, - isTiebreaker, isCustomFlow, }: { number: number; isCounterpicks: boolean; - isTiebreaker: boolean; isCustomFlow?: boolean; }) { + const { t } = useTranslation(["tournament"]); + return (
  • {number}. {isCustomFlow - ? "Custom flow" + ? t("tournament:mapList.customFlow") : isCounterpicks - ? "Counterpick" - : isTiebreaker - ? "Tiebreaker" - : "Team's pick"} + ? t("tournament:pickInfo.counterpick") + : t("tournament:mapList.teamsPick")}
  • ); diff --git a/app/features/tournament-bracket/core/PickBan.test.ts b/app/features/tournament-bracket/core/PickBan.test.ts index da9653338..963d83e90 100644 --- a/app/features/tournament-bracket/core/PickBan.test.ts +++ b/app/features/tournament-bracket/core/PickBan.test.ts @@ -959,7 +959,6 @@ describe("mapsListWithLegality — MODE_PICK restriction survives intervening ev mapList: null, teams, pickerTeamId: 100, - tieBreakerMapPool: [], toSetMapPool, pickBanEvents, }); @@ -1006,7 +1005,6 @@ describe("mapsListWithLegality — MODE_PICK restriction survives intervening ev mapList: null, teams, pickerTeamId: 100, - tieBreakerMapPool: [], toSetMapPool, pickBanEvents, }); @@ -1067,7 +1065,6 @@ describe("mapsListWithLegality — pre-set MODE_PICK only restricts the first ma mapList: null, teams, pickerTeamId: 100, - tieBreakerMapPool: [], toSetMapPool, pickBanEvents, }); @@ -1122,7 +1119,6 @@ describe("mapsListWithLegality — PICK_NO_MODE_REPEAT", () => { mapList: null, teams, pickerTeamId: 100, - tieBreakerMapPool: [], toSetMapPool, pickBanEvents, }); @@ -1157,7 +1153,6 @@ describe("mapsListWithLegality — PICK_NO_MODE_REPEAT", () => { mapList: null, teams, pickerTeamId: 100, - tieBreakerMapPool: [], toSetMapPool, pickBanEvents, }); @@ -1220,7 +1215,6 @@ describe("mapsListWithLegality — pre-set MODE_BAN persists into postGame", () mapList: null, teams, pickerTeamId: 100, - tieBreakerMapPool: [], toSetMapPool, pickBanEvents, }); @@ -1271,7 +1265,6 @@ describe("isModeLegal", () => { mapList: null, teams, pickerTeamId: 100, - tieBreakerMapPool: [], toSetMapPool, }; diff --git a/app/features/tournament-bracket/core/PickBan.ts b/app/features/tournament-bracket/core/PickBan.ts index f58202704..0e2eb4a96 100644 --- a/app/features/tournament-bracket/core/PickBan.ts +++ b/app/features/tournament-bracket/core/PickBan.ts @@ -532,7 +532,7 @@ interface MapListWithStatusesArgs { mapList: TournamentMapListMap[] | null; teams: [MapPoolTeam, MapPoolTeam]; pickerTeamId: number; - tieBreakerMapPool: ModeWithStage[]; + /** The organizer's maps, empty when the teams picked their own (the legal maps are then the union of their picks). */ toSetMapPool: Array<{ mode: ModeShort; stageId: StageId }>; pickBanEvents?: PickBanEvent[]; } @@ -553,7 +553,6 @@ export function mapsListWithLegality(args: MapListWithStatusesArgs) { const combinedPools = [ ...(args.teams[0].mapPool ?? []), ...(args.teams[1].mapPool ?? []), - ...args.tieBreakerMapPool, ]; const result: ModeWithStage[] = []; @@ -580,7 +579,6 @@ export function mapsListWithLegality(args: MapListWithStatusesArgs) { const combinedPools = [ ...(args.teams[0].mapPool ?? []), ...(args.teams[1].mapPool ?? []), - ...args.tieBreakerMapPool, ]; return R.uniqueBy(combinedPools, (m) => `${m.mode}-${m.stageId}`); diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index d0a1744b8..6561bfacc 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -1,6 +1,11 @@ import { sub } from "date-fns"; import type { Tables } from "~/db/tables"; -import type { TournamentStageSettings } from "~/db/tables-json"; +import type { + TeamPickSettings, + TournamentStageSettings, +} from "~/db/tables-json"; +import { MapPool } from "~/features/map-list-generator/core/map-pool"; +import * as TeamPick from "~/features/tournament/core/TeamPick"; import { TOURNAMENT } from "~/features/tournament/tournament-constants"; import { modesIncluded, @@ -10,6 +15,7 @@ import { } from "~/features/tournament/tournament-utils"; import type { MatchData } from "~/features/tournament-bracket/core/engine/types"; import * as Progression from "~/features/tournament-bracket/core/Progression"; +import { rankedModesShort } from "~/modules/in-game-lists/modes"; import type { ModeShort } from "~/modules/in-game-lists/types"; import { hasPermission } from "~/modules/permissions/utils"; import { @@ -691,17 +697,50 @@ export class Tournament { return this.ctx.mapPickingStyle !== "TO"; } - /** What Splatoon modes are played in this tournament */ - get modesIncluded(): ModeShort[] { - return modesIncluded(this.ctx.mapPickingStyle, this.ctx.toSetMapPool); + /** Team pick configuration, null when the organizer picks the maps. */ + get teamPickSettings(): TeamPickSettings | null { + if (!this.teamsPrePickMaps) return null; + + return ( + this.ctx.settings.teamPick ?? TeamPick.defaultSettings(rankedModesShort) + ); } - /** Rules page (and its nav item) is shown if there are rules or any map pool. */ + /** What Splatoon modes are played in this tournament */ + get modesIncluded(): ModeShort[] { + return modesIncluded( + this.teamPickSettings ?? undefined, + this.ctx.toSetMapPool, + ); + } + + /** Pool the maps of the tournament come from: the organizer's pool, or for team picked tournaments the pool the teams pick from. */ + get mapPool(): MapPool { + const teamPick = this.teamPickSettings; + if (!teamPick) return new MapPool(this.ctx.toSetMapPool); + + return TeamPick.effectivePool(teamPick, this.ctx.toSetMapPool); + } + + /** Organizer picked maps, empty when the teams pick their own (their custom pool is not legal as such). */ + get organizerPickedMapPool() { + return this.teamsPrePickMaps ? [] : this.ctx.toSetMapPool; + } + + /** In how many modes a stage may appear in a team's picks, see {@link TeamPick.stageRepeatCap}. */ + get stageRepeatCap() { + const teamPick = this.teamPickSettings; + if (!teamPick) return null; + + return TeamPick.stageRepeatCap({ teamPick, pool: this.mapPool }); + } + + /** Rules page (and its nav item) is shown if there are rules or maps to describe. */ get hasRulesPage() { return ( this.ctx.hasRules || this.ctx.toSetMapPool.length > 0 || - this.ctx.tieBreakerMapPool.length > 0 + this.teamsPrePickMaps ); } diff --git a/app/features/tournament-bracket/core/tests/mocks-li.ts b/app/features/tournament-bracket/core/tests/mocks-li.ts index 68e1cb5e2..c56bdca2d 100644 --- a/app/features/tournament-bracket/core/tests/mocks-li.ts +++ b/app/features/tournament-bracket/core/tests/mocks-li.ts @@ -8594,7 +8594,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ avgSeedingSkillOrdinal: -5.653760133881115, }, ], - tieBreakerMapPool: [], toSetMapPool: [ { mode: "CB", diff --git a/app/features/tournament-bracket/core/tests/mocks-sos.ts b/app/features/tournament-bracket/core/tests/mocks-sos.ts index 193ee1373..fffb169ee 100644 --- a/app/features/tournament-bracket/core/tests/mocks-sos.ts +++ b/app/features/tournament-bracket/core/tests/mocks-sos.ts @@ -4173,7 +4173,6 @@ export const SWIM_OR_SINK_167 = ( abDivision: null, }, ], - tieBreakerMapPool: [], toSetMapPool: [ { mode: "CB", diff --git a/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts b/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts index a73a1b4d0..e3eef1edf 100644 --- a/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts +++ b/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts @@ -533,7 +533,6 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({ avgSeedingSkillOrdinal: -6.382139240461566, }, ], - tieBreakerMapPool: [], toSetMapPool: [ { mode: "SZ", diff --git a/app/features/tournament-bracket/core/tests/mocks.ts b/app/features/tournament-bracket/core/tests/mocks.ts index a62353dc4..82acd989f 100644 --- a/app/features/tournament-bracket/core/tests/mocks.ts +++ b/app/features/tournament-bracket/core/tests/mocks.ts @@ -1262,7 +1262,7 @@ export const PADDLING_POOL_257 = () => ], lockedMatches: [], }, - mapPickingStyle: "AUTO_ALL", + mapPickingStyle: "AUTO", name: "Paddling Pool 257", hasRules: false, logoUrl: "/test.avif", @@ -2517,24 +2517,6 @@ export const PADDLING_POOL_257 = () => }, ], toSetMapPool: [], - tieBreakerMapPool: [ - { - stageId: 15, - mode: "SZ", - }, - { - stageId: 0, - mode: "CB", - }, - { - stageId: 16, - mode: "RM", - }, - { - stageId: 8, - mode: "TC", - }, - ], latestTeamIdByDuplicatedUserId: {}, }, }) as TournamentData; @@ -3876,7 +3858,7 @@ export const PADDLING_POOL_255 = () => ], lockedMatches: [], }, - mapPickingStyle: "AUTO_ALL", + mapPickingStyle: "AUTO", name: "Paddling Pool 255", hasRules: false, logoUrl: "/test.avif", @@ -5098,24 +5080,6 @@ export const PADDLING_POOL_255 = () => }, ], toSetMapPool: [], - tieBreakerMapPool: [ - { - stageId: 15, - mode: "SZ", - }, - { - stageId: 0, - mode: "CB", - }, - { - stageId: 16, - mode: "RM", - }, - { - stageId: 8, - mode: "TC", - }, - ], latestTeamIdByDuplicatedUserId: {}, }, }) as TournamentData; @@ -6341,6 +6305,7 @@ export const IN_THE_ZONE_32 = ({ eventId: 1134, bracketProgressionOverrides: [], settings: { + teamPick: { modes: [{ mode: "SZ", count: 6 }], pool: "SENDOUQ" }, bracketProgression: [ { name: "Main bracket", @@ -6363,7 +6328,7 @@ export const IN_THE_ZONE_32 = ({ discordUrl: null, castTwitchAccounts: ["dappleproductions", "kyochandxd"], castedMatchesInfo: null, - mapPickingStyle: "AUTO_SZ", + mapPickingStyle: "AUTO", name: "In The Zone 32", hasRules: false, logoUrl: "/test.avif", @@ -7281,7 +7246,6 @@ export const IN_THE_ZONE_32 = ({ }, ], toSetMapPool: [], - tieBreakerMapPool: [], latestTeamIdByDuplicatedUserId: {}, }, }) as TournamentData; diff --git a/app/features/tournament-bracket/core/tests/test-utils.ts b/app/features/tournament-bracket/core/tests/test-utils.ts index 9a090f06b..bc35aa250 100644 --- a/app/features/tournament-bracket/core/tests/test-utils.ts +++ b/app/features/tournament-bracket/core/tests/test-utils.ts @@ -71,11 +71,11 @@ export const testTournament = ({ castTwitchAccounts: [], bracketProgressionOverrides: [], staff: [], - tieBreakerMapPool: [], toSetMapPool: [], latestTeamIdByDuplicatedUserId: {}, - mapPickingStyle: "AUTO_SZ", + mapPickingStyle: "AUTO", settings: { + teamPick: { modes: [{ mode: "SZ", count: 6 }], pool: "SENDOUQ" }, bracketProgression: [ { name: "Main Bracket", diff --git a/app/features/tournament-bracket/core/toMapList.test.ts b/app/features/tournament-bracket/core/toMapList.test.ts new file mode 100644 index 000000000..021ecb6a6 --- /dev/null +++ b/app/features/tournament-bracket/core/toMapList.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, test } from "vitest"; +import type { Tables } from "~/db/tables"; +import { SENDOUQ_MAP_POOL } from "~/features/match-profile/banned-maps"; +import { rankedModesShort } from "~/modules/in-game-lists/modes"; +import type { ModeShort } from "~/modules/in-game-lists/types"; +import type { RoundData } from "./engine/types"; +import { + type BracketMapCounts, + type GenerateTournamentRoundMaplistArgs, + generateTournamentRoundMaplist, +} from "./toMapList"; + +const MAPS_PER_ROUND = 3; + +const rankedPool = SENDOUQ_MAP_POOL.stageModePairs.filter( + (pair) => pair.mode !== "TW", +); + +/** Rounds of the given groups, ids running from 1 in group order. */ +const roundsOf = ( + groups: Array<{ groupId: number; roundCount: number }>, +): RoundData[] => { + const rounds: RoundData[] = []; + for (const { groupId, roundCount } of groups) { + for (let number = 1; number <= roundCount; number++) { + rounds.push({ id: rounds.length + 1, stageId: 1, groupId, number }); + } + } + + return rounds; +}; + +const mapCountsOf = (rounds: RoundData[]): BracketMapCounts => { + const counts: BracketMapCounts = new Map(); + for (const round of rounds) { + const group = counts.get(round.groupId) ?? new Map(); + group.set(round.number, { count: MAPS_PER_ROUND, type: "BEST_OF" }); + counts.set(round.groupId, group); + } + + return counts; +}; + +const singleGroupRounds = roundsOf([{ groupId: 0, roundCount: 3 }]); + +const generate = ( + overrides: Partial & { + rounds?: RoundData[]; + type?: Tables["TournamentStage"]["type"]; + } = {}, +) => { + const rounds = overrides.rounds ?? singleGroupRounds; + + return generateTournamentRoundMaplist({ + pool: rankedPool, + teamsPickMaps: true, + rounds, + mapCounts: mapCountsOf(rounds), + type: "single_elimination", + roundsWithPickBan: new Set(), + pickBanStyle: null, + patterns: new Map(), + countType: "BEST_OF", + ...overrides, + }); +}; + +const modesOf = (result: ReturnType) => + Array.from(result.values()).map((round) => round.modes ?? []); + +describe("generateTournamentRoundMaplist", () => { + test("team picked rounds get a mode order instead of a map list", () => { + const result = generate(); + + expect(result.size).toBe(singleGroupRounds.length); + for (const round of result.values()) { + expect(round.list).toBeNull(); + expect(round.count).toBe(MAPS_PER_ROUND); + expect(round.modes).toHaveLength(MAPS_PER_ROUND); + for (const mode of round.modes ?? []) { + expect(rankedModesShort).toContain(mode); + } + } + }); + + test("successive team picked rounds continue the mode rotation instead of restarting it", () => { + const modes = modesOf(generate()).flat(); + const cycle = modes.slice(0, rankedModesShort.length); + + expect(new Set(cycle).size).toBe(rankedModesShort.length); + for (const [i, mode] of modes.entries()) { + expect(mode).toBe(cycle[i % cycle.length]); + } + }); + + test("a mode pattern fixes the modes of every team picked round", () => { + const [first, second, third] = modesOf( + generate({ patterns: new Map([[MAPS_PER_ROUND, "SZ*TC"]]) }), + ); + + for (const modes of [first, second, third]) { + expect(modes[0]).toBe("SZ"); + expect(modes[2]).toBe("TC"); + expect(["RM", "CB"]).toContain(modes[1]); + } + // the wildcard slot keeps rotating through the modes the pattern leaves free + expect(second[1]).not.toBe(first[1]); + expect(third[1]).toBe(first[1]); + }); + + test("a BAN_2 round gets two more modes than its map count", () => { + const result = generate({ + roundsWithPickBan: new Set([singleGroupRounds[0].id]), + pickBanStyle: "BAN_2", + }); + + expect(result.get(singleGroupRounds[0].id)).toMatchObject({ + count: MAPS_PER_ROUND, + pickBan: "BAN_2", + }); + expect(result.get(singleGroupRounds[0].id)?.modes).toHaveLength( + MAPS_PER_ROUND + 2, + ); + expect(result.get(singleGroupRounds[1].id)?.pickBan).toBeUndefined(); + expect(result.get(singleGroupRounds[1].id)?.modes).toHaveLength( + MAPS_PER_ROUND, + ); + }); + + test.each([ + { pickBanStyle: "COUNTERPICK", expectedModes: 1 }, + { pickBanStyle: "COUNTERPICK_MODE_REPEAT_OK", expectedModes: 1 }, + ] as const)( + "a $pickBanStyle round gets only its starting mode", + ({ pickBanStyle, expectedModes }) => { + const result = generate({ + roundsWithPickBan: new Set([singleGroupRounds[0].id]), + pickBanStyle, + }); + + expect(result.get(singleGroupRounds[0].id)?.modes).toHaveLength( + expectedModes, + ); + }, + ); + + test("organizer picked rounds get a map list from the pool and no mode order", () => { + const result = generate({ teamsPickMaps: false }); + + for (const round of result.values()) { + expect(round.modes).toBeUndefined(); + expect(round.list).toHaveLength(MAPS_PER_ROUND); + for (const map of round.list ?? []) { + expect(rankedPool).toContainEqual(map); + } + } + }); + + test("round robin groups share the map list of the group with the most rounds", () => { + const rounds = roundsOf([ + { groupId: 0, roundCount: 2 }, + { groupId: 1, roundCount: 3 }, + ]); + + const result = generate({ rounds, type: "round_robin" }); + + expect(Array.from(result.keys())).toEqual( + rounds.filter((round) => round.groupId === 1).map((round) => round.id), + ); + }); + + test("mode order only contains modes of the pool", () => { + const pool = rankedPool.filter((pair) => pair.mode === "SZ"); + + const modes = modesOf(generate({ pool })).flat(); + + expect(modes).toHaveLength(singleGroupRounds.length * MAPS_PER_ROUND); + expect(modes.every((mode: ModeShort) => mode === "SZ")).toBe(true); + }); +}); diff --git a/app/features/tournament-bracket/core/toMapList.ts b/app/features/tournament-bracket/core/toMapList.ts index 1ca1be847..47db58a2c 100644 --- a/app/features/tournament-bracket/core/toMapList.ts +++ b/app/features/tournament-bracket/core/toMapList.ts @@ -1,4 +1,4 @@ -/** Map list generation for "TO pick": the map list is defined beforehand by the TO. */ +/** Map list generation for "TO pick": the map list is defined beforehand by the TO. For "team pick" only the mode order is generated, the maps come from the teams' picks. */ import type { Tables } from "~/db/tables"; import type { TournamentRoundMaps } from "~/db/tables-json"; @@ -17,7 +17,10 @@ export type BracketMapCounts = Map< >; export interface GenerateTournamentRoundMaplistArgs { + /** The tournament's effective map pool, see `Tournament.mapPool`. */ pool: Array<{ mode: ModeShort; stageId: StageId }>; + /** Teams pick the maps: rounds get a mode order from the pattern instead of a map list. */ + teamsPickMaps: boolean; rounds: RoundData[]; mapCounts: BracketMapCounts; type: Tables["TournamentStage"]["type"]; @@ -70,20 +73,30 @@ export function generateTournamentRoundMaplist( const pattern = args.patterns.get(count); + const generated = () => + generator.next({ + amount: amountOfMapsToGenerate(), + pattern, + }).value; + + if (args.teamsPickMaps) { + result.set(round.id, { + count, + pickBan: args.roundsWithPickBan.has(round.id) + ? args.pickBanStyle + : undefined, + list: null, + modes: generated().map((map) => map.mode), + }); + continue; + } + result.set(round.id, { count, pickBan: args.roundsWithPickBan.has(round.id) ? args.pickBanStyle : undefined, - list: - // teams pick - args.pool.length === 0 - ? null - : // TO pick - generator.next({ - amount: amountOfMapsToGenerate(), - pattern, - }).value, + list: args.pool.length === 0 ? null : generated(), }); } diff --git a/app/features/tournament-bracket/tournament-bracket-schemas.ts b/app/features/tournament-bracket/tournament-bracket-schemas.ts index a82f247b4..a8c99122f 100644 --- a/app/features/tournament-bracket/tournament-bracket-schemas.ts +++ b/app/features/tournament-bracket/tournament-bracket-schemas.ts @@ -128,6 +128,7 @@ const tournamentRoundMaps = v.object({ ), ), ), + modes: v.optional(v.nullable(v.array(modeShort))), count: numericEnum(TOURNAMENT.AVAILABLE_BEST_OF), type: v.picklist(["BEST_OF", "PLAY_ALL"]), pickBan: v.nullish(v.picklist(PickBan.types)), diff --git a/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts b/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts index 97453502a..52b489a08 100644 --- a/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts +++ b/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts @@ -364,12 +364,8 @@ export const action: ActionFunction = async ({ params, request }) => { const pickBanLegalityArgs = { results, maps: match.roundMaps, - toSetMapPool: - tournament.ctx.mapPickingStyle === "TO" - ? await TournamentRepository.findTOSetMapPoolById(tournamentId) - : [], + toSetMapPool: tournament.organizerPickedMapPool, mapList, - tieBreakerMapPool: tournament.ctx.tieBreakerMapPool, teams: [teamOne, teamTwo] as [PickBan.MapPoolTeam, PickBan.MapPoolTeam], pickerTeamId, pickBanEvents: currentPickBanEvents, @@ -455,9 +451,8 @@ export const action: ActionFunction = async ({ params, request }) => { maps: match.roundMaps, pickBanEvents: updatedEvents, results, - tournamentId, teams: [teamOne, teamTwo], - tieBreakerMapPool: tournament.ctx.tieBreakerMapPool, + toSetMapPool: tournament.organizerPickedMapPool, }); } diff --git a/app/features/tournament-match/components/TournamentMatchActionPickBanTab.tsx b/app/features/tournament-match/components/TournamentMatchActionPickBanTab.tsx index 0edeb2498..ecd42842d 100644 --- a/app/features/tournament-match/components/TournamentMatchActionPickBanTab.tsx +++ b/app/features/tournament-match/components/TournamentMatchActionPickBanTab.tsx @@ -37,11 +37,10 @@ export function TournamentMatchActionPickBanTab({ (user ? pickingTeam.memberUserIds.includes(user.id) : false); const pickBanMapPool = PickBan.mapsListWithLegality({ - toSetMapPool: tournament.ctx.toSetMapPool, + toSetMapPool: tournament.organizerPickedMapPool, maps: data.match.roundMaps, mapList: data.mapList, teams, - tieBreakerMapPool: tournament.ctx.tieBreakerMapPool, pickerTeamId, results: data.results, pickBanEvents: data.pickBanEvents, diff --git a/app/features/tournament-match/core/executeRoll.server.ts b/app/features/tournament-match/core/executeRoll.server.ts index ea9e5e66a..565201a44 100644 --- a/app/features/tournament-match/core/executeRoll.server.ts +++ b/app/features/tournament-match/core/executeRoll.server.ts @@ -12,9 +12,8 @@ export async function executeRoll({ maps, pickBanEvents, results, - tournamentId, teams, - tieBreakerMapPool, + toSetMapPool, }: { matchId: number; maps: TournamentRoundMaps; @@ -22,9 +21,9 @@ export async function executeRoll({ ReturnType >; results: Awaited>; - tournamentId: number; teams: [PickBan.MapPoolTeam, PickBan.MapPoolTeam]; - tieBreakerMapPool: ModeWithStage[]; + /** See `Tournament.organizerPickedMapPool`. */ + toSetMapPool: ModeWithStage[]; }): Promise { const customFlow = maps.customFlow; if (!customFlow) return false; @@ -38,14 +37,11 @@ export async function executeRoll({ if (step?.action !== "ROLL") return false; - const toSetMapPool = - await TournamentRepository.findTOSetMapPoolById(tournamentId); const legalMaps = PickBan.mapsListWithLegality({ toSetMapPool, maps, mapList: null, teams, - tieBreakerMapPool, pickerTeamId: teams[0].id, results, pickBanEvents, diff --git a/app/features/tournament-match/core/mapList.server.test.ts b/app/features/tournament-match/core/mapList.server.test.ts new file mode 100644 index 000000000..17bf666d8 --- /dev/null +++ b/app/features/tournament-match/core/mapList.server.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, test } from "vitest"; +import { MapPool } from "~/features/map-list-generator/core/map-pool"; +import { SENDOUQ_MAP_POOL } from "~/features/match-profile/banned-maps"; +import { rankedModesShort } from "~/modules/in-game-lists/modes"; +import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; +import { resolveMapList } from "./mapList.server"; + +const TEAM_ONE_ID = 1; +const TEAM_TWO_ID = 2; + +const map = (mode: ModeShort, stageId: StageId) => ({ mode, stageId }); + +const teamOnePicks = [ + map("SZ", 4), + map("SZ", 5), + map("TC", 5), + map("TC", 6), + map("RM", 7), + map("RM", 8), + map("CB", 9), + map("CB", 10), +]; +const teamTwoPicks = [ + map("SZ", 11), + map("SZ", 9), + map("TC", 2), + map("TC", 8), + map("RM", 7), + map("RM", 1), + map("CB", 2), + map("CB", 3), +]; +const organizerList = [map("SZ", 1), map("TC", 2), map("RM", 3)]; + +type ResolveArgs = Parameters[0]; + +const pickedByEitherTeam = (candidate: { mode: ModeShort; stageId: StageId }) => + [...teamOnePicks, ...teamTwoPicks].some( + (pick) => + pick.mode === candidate.mode && pick.stageId === candidate.stageId, + ); + +/** The resolved list is cached per match, so every test resolving a fresh list names its own match. */ +const resolve = ({ + matchId, + ...overrides +}: Partial & { matchId: number }) => + resolveMapList({ + tournamentId: 1, + matchId, + mapPickingStyle: "AUTO", + teams: [TEAM_ONE_ID, TEAM_TWO_ID], + mapPoolByTeamId: (teamId) => + teamId === TEAM_ONE_ID ? teamOnePicks : teamTwoPicks, + maps: { count: 3, type: "BEST_OF" }, + pool: SENDOUQ_MAP_POOL, + modesIncluded: [...rankedModesShort], + pickBanEvents: [], + ...overrides, + }); + +describe("resolveMapList", () => { + test("organizer picked round returns the round's list with the organizer as the source", () => { + expect( + resolve({ + matchId: 1, + mapPickingStyle: "TO", + maps: { count: 3, type: "BEST_OF", list: organizerList }, + }), + ).toEqual( + organizerList.map((pick) => ({ + ...pick, + source: "TO", + bannedByTournamentTeamId: undefined, + })), + ); + }); + + test("organizer picked round without a list is empty", () => { + expect(resolve({ matchId: 2, mapPickingStyle: "TO" })).toEqual([]); + }); + + test("team picked round is generated from both teams' picks", () => { + const mapList = resolve({ matchId: 3 }); + + expect(mapList).toHaveLength(3); + expect(new Set(mapList.map((m) => m.mode)).size).toBe(3); + for (const resolved of mapList) { + expect([TEAM_ONE_ID, TEAM_TWO_ID, "BOTH", "RANDOM"]).toContain( + resolved.source, + ); + expect( + pickedByEitherTeam(resolved) || SENDOUQ_MAP_POOL.has(resolved), + ).toBe(true); + } + }); + + test("team picked round follows the round's mode order", () => { + const mapList = resolve({ + matchId: 4, + maps: { count: 3, type: "BEST_OF", modes: ["CB", "SZ", "TC"] }, + }); + + expect(mapList.map((m) => m.mode)).toEqual(["CB", "SZ", "TC"]); + }); + + test("the same match resolves to the same list every time", () => { + expect(resolve({ matchId: 5 })).toEqual(resolve({ matchId: 5 })); + }); + + test("BAN_2 round has two extra maps, the bans attributed to the banning teams", () => { + const maps = { count: 3, type: "BEST_OF", pickBan: "BAN_2" } as const; + const beforeBans = resolve({ matchId: 6, maps }); + + expect(beforeBans).toHaveLength(5); + expect( + beforeBans.every((m) => m.bannedByTournamentTeamId === undefined), + ).toBe(true); + + const afterBans = resolve({ + matchId: 6, + maps, + pickBanEvents: [ + { + type: "BAN", + mode: beforeBans[0].mode, + stageId: beforeBans[0].stageId, + }, + { + type: "BAN", + mode: beforeBans[1].mode, + stageId: beforeBans[1].stageId, + }, + ], + }); + + // the team picking second bans first + expect(afterBans[0].bannedByTournamentTeamId).toBe(TEAM_TWO_ID); + expect(afterBans[1].bannedByTournamentTeamId).toBe(TEAM_ONE_ID); + expect( + afterBans.slice(2).every((m) => m.bannedByTournamentTeamId === undefined), + ).toBe(true); + }); + + test("counterpick round starts with one map of the round's first mode and appends the picks", () => { + const mapList = resolve({ + matchId: 7, + maps: { + count: 5, + type: "BEST_OF", + pickBan: "COUNTERPICK", + modes: ["RM", "SZ", "TC", "CB", "RM"], + }, + pickBanEvents: [{ type: "PICK", mode: "SZ", stageId: 1 }], + }); + + expect(mapList).toHaveLength(2); + expect(mapList[0].mode).toBe("RM"); + expect(mapList[1]).toEqual({ + mode: "SZ", + stageId: 1, + source: "COUNTERPICK", + bannedByTournamentTeamId: undefined, + }); + }); + + test("custom flow lists the rolls and picks of the events in order", () => { + const mapList = resolve({ + matchId: 8, + maps: { count: 3, type: "BEST_OF", pickBan: "CUSTOM" }, + pickBanEvents: [ + { type: "ROLL", mode: "SZ", stageId: 1 }, + { type: "BAN", mode: "RM", stageId: 3 }, + { type: "PICK", mode: "TC", stageId: 2 }, + ], + }); + + expect(mapList).toEqual([ + { + mode: "SZ", + stageId: 1, + source: "ROLL", + bannedByTournamentTeamId: undefined, + }, + { + mode: "TC", + stageId: 2, + source: "COUNTERPICK", + bannedByTournamentTeamId: undefined, + }, + ]); + }); + + test("without either team's picks the list is random maps of the pool", () => { + const mapList = resolve({ matchId: 9, mapPoolByTeamId: () => [] }); + + expect(mapList).toHaveLength(3); + expect(new Set(mapList.map((m) => m.stageId)).size).toBe(3); + for (const resolved of mapList) { + expect(resolved.source).toBe("RANDOM"); + expect(SENDOUQ_MAP_POOL.has(resolved)).toBe(true); + } + }); + + test("random maps are limited to the tournament's pool and modes", () => { + const pool = new MapPool({ ...MapPool.EMPTY.parsed, SZ: [1, 2, 3, 4, 5] }); + + const mapList = resolve({ + matchId: 10, + mapPoolByTeamId: () => [], + pool, + modesIncluded: ["SZ"], + }); + + expect(mapList).toHaveLength(3); + for (const resolved of mapList) { + expect(resolved.mode).toBe("SZ"); + expect(pool.has(resolved)).toBe(true); + } + }); +}); diff --git a/app/features/tournament-match/core/mapList.server.ts b/app/features/tournament-match/core/mapList.server.ts index 08da4006d..f018944b1 100644 --- a/app/features/tournament-match/core/mapList.server.ts +++ b/app/features/tournament-match/core/mapList.server.ts @@ -3,7 +3,6 @@ import type { TournamentRoundMaps } from "~/db/tables-json"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; -import { mapPickingStyleToModes } from "~/features/tournament/tournament-utils"; import type * as PickBan from "~/features/tournament-bracket/core/PickBan"; import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; @@ -30,7 +29,9 @@ interface ResolveCurrentMapListArgs { teamId: number, ) => Array<{ mode: ModeShort; stageId: StageId }>; maps: TournamentRoundMaps; - tieBreakerMapPool: Array<{ mode: ModeShort; stageId: StageId }>; + /** The tournament's effective map pool, see `Tournament.mapPool`. */ + pool: MapPool; + modesIncluded: ModeShort[]; pickBanEvents: Array<{ mode: ModeShort | null; stageId: StageId | null; @@ -133,7 +134,8 @@ export async function resolveMatchMapList({ mapPoolByTeamId: (teamId) => mapPools.get(teamId) ?? [], mapPickingStyle: match.mapPickingStyle, maps: match.roundMaps, - tieBreakerMapPool: tournament.ctx.tieBreakerMapPool, + pool: tournament.mapPool, + modesIncluded: tournament.modesIncluded, pickBanEvents, recentlyPlayedMaps, }); @@ -202,9 +204,6 @@ function resolveFreshTeamPickedMapList( mapPickingStyle: Exclude; }, ) { - const tieBreakerMapPool = - args.mapPickingStyle === "AUTO_ALL" ? args.tieBreakerMapPool : []; - const pickBanCount = (pickBan: PickBan.Type, baseCount: number) => { switch (pickBan) { case "BAN_2": @@ -227,55 +226,39 @@ function resolveFreshTeamPickedMapList( return args.maps.count; }; + const generatorInput = { + seed: String(args.matchId), + modesIncluded: args.modesIncluded, + pool: args.pool, + modeOrder: args.maps.modes, + recentlyPlayedMaps: args.recentlyPlayedMaps, + }; + + const teams = args.teams.map((teamId) => ({ + id: teamId, + maps: new MapPool(args.mapPoolByTeamId(teamId)), + })) as [{ id: number; maps: MapPool }, { id: number; maps: MapPool }]; + if (count() === 1) { - return starterMap({ - seed: String(args.matchId), - modesIncluded: mapPickingStyleToModes(args.mapPickingStyle), - tiebreakerMaps: new MapPool(tieBreakerMapPool), - teams: [ - { - id: args.teams[0], - maps: new MapPool(args.mapPoolByTeamId(args.teams[0])), - }, - { - id: args.teams[1], - maps: new MapPool(args.mapPoolByTeamId(args.teams[1])), - }, - ], - recentlyPlayedMaps: args.recentlyPlayedMaps, - }); + return starterMap({ ...generatorInput, teams }); } const result = generateBalancedMapList({ + ...generatorInput, count: count(), - seed: String(args.matchId), - modesIncluded: mapPickingStyleToModes(args.mapPickingStyle), - tiebreakerMaps: new MapPool(tieBreakerMapPool), - teams: [ - { - id: args.teams[0], - maps: new MapPool(args.mapPoolByTeamId(args.teams[0])), - }, - { - id: args.teams[1], - maps: new MapPool(args.mapPoolByTeamId(args.teams[1])), - }, - ], - recentlyPlayedMaps: args.recentlyPlayedMaps, + teams, }); if (result.ok) return result.value; logger.error( - "Failed to create map list. Falling back to default maps.", + "Failed to create map list. Falling back to random maps from the pool.", result.error, ); return unwrap( generateBalancedMapList({ + ...generatorInput, count: count(), - seed: String(args.matchId), - modesIncluded: mapPickingStyleToModes(args.mapPickingStyle), - tiebreakerMaps: new MapPool(tieBreakerMapPool), teams: [ { id: -1, @@ -286,7 +269,6 @@ function resolveFreshTeamPickedMapList( maps: new MapPool([]), }, ], - recentlyPlayedMaps: args.recentlyPlayedMaps, }), ); } diff --git a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts index 86f59280e..d4dc92e68 100644 --- a/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts +++ b/app/features/tournament-match/loaders/to.$id.matches.$mid.server.ts @@ -93,9 +93,8 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { maps: match.roundMaps, pickBanEvents, results, - tournamentId, teams: [teamOne, teamTwo], - tieBreakerMapPool: tournament.ctx.tieBreakerMapPool, + toSetMapPool: tournament.organizerPickedMapPool, }); if (rollExecuted) { pickBanEvents = await TournamentRepository.findPickBanEventsByMatchId( @@ -133,7 +132,8 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { mapPoolByTeamId: (teamId) => teamFullById(teamId)?.mapPool ?? [], mapPickingStyle: match.mapPickingStyle, maps: match.roundMaps, - tieBreakerMapPool: tournament.ctx.tieBreakerMapPool, + pool: tournament.mapPool, + modesIncluded: tournament.modesIncluded, pickBanEvents, recentlyPlayedMaps: match.mapPickingStyle !== "TO" diff --git a/app/features/tournament-match/tournament-match-utils.ts b/app/features/tournament-match/tournament-match-utils.ts index 0cab18398..6c33dbe98 100644 --- a/app/features/tournament-match/tournament-match-utils.ts +++ b/app/features/tournament-match/tournament-match-utils.ts @@ -72,8 +72,8 @@ export function pickInfoText({ if (map.source === teams[1].id) { return t("tournament:pickInfo.team", { number: 2 }); } - if (map.source === "TIEBREAKER") { - return t("tournament:pickInfo.tiebreaker"); + if (map.source === "RANDOM") { + return t("tournament:pickInfo.random"); } if (map.source === "BOTH") return t("tournament:pickInfo.both"); if (map.source === "DEFAULT") return t("tournament:pickInfo.default"); diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index 8645a3e17..370b833bf 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -252,16 +252,6 @@ export async function findById(id: number) { .orderBy("TournamentTeam.createdAt", "asc") .orderBy("TournamentTeam.id", "asc"), ).as("teams"), - jsonArrayFrom( - eb - .selectFrom("MapPoolMap") - .select(["MapPoolMap.stageId", "MapPoolMap.mode"]) - .whereRef( - "MapPoolMap.tieBreakerCalendarEventId", - "=", - "CalendarEvent.id", - ), - ).as("tieBreakerMapPool"), jsonArrayFrom( eb .selectFrom("MapPoolMap") diff --git a/app/features/tournament/actions/to.$id.register.server.ts b/app/features/tournament/actions/to.$id.register.server.ts index 14011de22..a38914ddd 100644 --- a/app/features/tournament/actions/to.$id.register.server.ts +++ b/app/features/tournament/actions/to.$id.register.server.ts @@ -23,11 +23,8 @@ import { logger } from "~/utils/logger"; import { errorToastIfFalsy, successToast } from "~/utils/remix.server"; import { toDBBoolean } from "~/utils/sql"; import { assertUnreachable } from "~/utils/types"; +import * as TeamPick from "../core/TeamPick"; import { registerSchema } from "../tournament-schemas.server"; -import { - isOneModeTournamentOf, - validateCounterPickMapPool, -} from "../tournament-utils"; import { fulfillsSendouQParticipation, isBannedByOrganization, @@ -223,15 +220,14 @@ export const action: ActionFunction = async ({ request, params }) => { case "UPDATE_MAP_POOL": { const mapPool = new MapPool(data.mapPool); errorToastIfFalsy(ownTeam, "You are not registered to this tournament"); + const teamPick = tournament.teamPickSettings; + errorToastIfFalsy(teamPick, "Teams don't pick maps in this tournament"); errorToastIfFalsy( - validateCounterPickMapPool( + TeamPick.validateTeamPool({ mapPool, - isOneModeTournamentOf( - tournament.ctx.mapPickingStyle, - tournament.ctx.toSetMapPool, - ), - tournament.ctx.tieBreakerMapPool, - ) === "VALID", + teamPick, + pool: tournament.mapPool, + }) === "VALID", "Invalid map pool", ); diff --git a/app/features/tournament/components/CounterPickMapPoolPicker.tsx b/app/features/tournament/components/CounterPickMapPoolPicker.tsx index e9ca7d28c..7d3614943 100644 --- a/app/features/tournament/components/CounterPickMapPoolPicker.tsx +++ b/app/features/tournament/components/CounterPickMapPoolPicker.tsx @@ -2,15 +2,10 @@ 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 { invariant } from "~/utils/invariant"; +import * as TeamPick from "../core/TeamPick"; import { useTournament } from "../tournament-context"; -import { - type CounterPickValidationStatus, - isOneModeTournamentOf, - validateCounterPickMapPool, -} from "../tournament-utils"; export type CounterPickMapPool = Array<{ mode: ModeShort; @@ -28,24 +23,19 @@ export function CounterPickMapPoolPicker({ disabled?: boolean; }) { const tournament = useTournament(); - const oneModeOnlyFor = oneModeTournamentOf(tournament); + const teamPick = tournament.teamPickSettings; + invariant(teamPick, "Tournament does not have teams pick maps"); + + const pool = tournament.mapPool; return ( <> - {tournament.modesIncluded.map((mode) => ( + {teamPick.modes.map(({ mode, count }) => ( stage.mode === mode, - )?.stageId - } + allowedStages={pool.parsed[mode]} pool={mapPool .filter((map) => map.mode === mode) .map((map) => map.stageId)} @@ -67,27 +57,29 @@ export function useCounterPickMapPoolValidationStatus( mapPool: CounterPickMapPool, ) { const tournament = useTournament(); + const teamPick = tournament.teamPickSettings; + invariant(teamPick, "Tournament does not have teams pick maps"); - return validateCounterPickMapPool( - new MapPool(mapPool), - oneModeTournamentOf(tournament), - tournament.ctx.tieBreakerMapPool, - ); + return TeamPick.validateTeamPool({ + mapPool: new MapPool(mapPool), + teamPick, + pool: tournament.mapPool, + }); } /** Explains why a counterpick map pool can't be saved. Renders nothing for statuses without an explanation. */ export function MapPoolValidationStatusMessage({ status, }: { - status: CounterPickValidationStatus; + status: TeamPick.TeamPoolValidationStatus; }) { const { t } = useTranslation(["common"]); + const tournament = useTournament(); if ( status !== "TOO_MUCH_STAGE_REPEAT" && status !== "STAGE_REPEAT_IN_SAME_MODE" && - status !== "INCLUDES_BANNED" && - status !== "INCLUDES_TIEBREAKER" + status !== "NOT_IN_POOL" ) { return null; } @@ -96,16 +88,9 @@ export function MapPoolValidationStatusMessage({
    {t(`common:maps.validation.${status}`, { - maxStageRepeat: TOURNAMENT.COUNTERPICK_MAX_STAGE_REPEAT, + maxStageRepeat: tournament.stageRepeatCap ?? 1, })}
    ); } - -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 475384096..73c1c6d90 100644 --- a/app/features/tournament/components/TeamWithRoster.tsx +++ b/app/features/tournament/components/TeamWithRoster.tsx @@ -7,6 +7,7 @@ import type { Tables } from "~/db/tables"; import { useUser } from "~/features/auth/core/user"; import { useTournament } from "~/features/tournament/tournament-context"; import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server"; +import { modesShort } from "~/modules/in-game-lists/modes"; import { userPage } from "~/utils/urls"; import { accountCreatedInTheLastSixMonths } from "~/utils/users"; import { useTournamentFriendCodes } from "../routes/to.$id"; @@ -142,13 +143,19 @@ function TeamMapPool({ }: { mapPool: Array>; }) { + const sortedMapPool = mapPool.toSorted( + (a, b) => + modesShort.indexOf(a.mode) - modesShort.indexOf(b.mode) || + a.stageId - b.stageId, + ); + return (
    - {mapPool.map(({ mode, stageId }, i) => { + {sortedMapPool.map(({ mode, stageId }, i) => { return (
    + Array.from({ length: count }, (_, i) => i as StageId); + +const uniformSettings = ( + modes: ModeShort[], + count: number, + pool: TeamPickSettings["pool"] = "SENDOUQ", +): TeamPickSettings => ({ + modes: modes.map((mode) => ({ mode, count })), + pool, +}); + +const sameStagesPool = (modes: ModeShort[], stageCount: number) => + new MapPool({ + ...MapPool.EMPTY.parsed, + ...Object.fromEntries(modes.map((mode) => [mode, stages(stageCount)])), + }); + +describe("TeamPick.defaultCount", () => { + test.each([ + { modeCount: 1, expected: 6 }, + { modeCount: 2, expected: 4 }, + { modeCount: 3, expected: 3 }, + { modeCount: 4, expected: 2 }, + { modeCount: 5, expected: 2 }, + ])("$modeCount modes -> $expected maps each", ({ modeCount, expected }) => { + expect(TeamPick.defaultCount(modeCount)).toBe(expected); + }); +}); + +describe("TeamPick.defaultSettings", () => { + test("sorts the modes in the in-game order with the SendouQ pool", () => { + expect(TeamPick.defaultSettings(["CB", "TW", "SZ"])).toEqual({ + modes: [ + { mode: "TW", count: 3 }, + { mode: "SZ", count: 3 }, + { mode: "CB", count: 3 }, + ], + pool: "SENDOUQ", + }); + }); +}); + +describe("TeamPick.effectivePool", () => { + test("SendouQ pool limited to the picked modes", () => { + const pool = TeamPick.effectivePool(uniformSettings(["SZ", "TC"], 2), []); + + expect(pool.parsed.SZ).toEqual(SENDOUQ_MAP_POOL.parsed.SZ); + expect(pool.parsed.TC).toEqual(SENDOUQ_MAP_POOL.parsed.TC); + expect(pool.parsed.RM).toEqual([]); + expect(pool.parsed.TW).toEqual([]); + }); + + test("all maps includes Turf War and banned stages", () => { + const pool = TeamPick.effectivePool( + uniformSettings(["TW", "TC"], 2, "ALL"), + [], + ); + + expect(pool.parsed.TW).toEqual(MapPool.ALL.parsed.TW); + expect(pool.parsed.TC).toEqual(MapPool.ALL.parsed.TC); + expect(pool.parsed.SZ).toEqual([]); + }); + + test("custom pool ignores maps of modes not picked", () => { + const pool = TeamPick.effectivePool(uniformSettings(["SZ"], 2, "CUSTOM"), [ + { mode: "SZ", stageId: 1 }, + { mode: "TC", stageId: 2 }, + ]); + + expect(pool.stageModePairs).toEqual([{ mode: "SZ", stageId: 1 }]); + }); +}); + +describe("TeamPick.stageRepeatCap", () => { + test.each([ + { + why: "4 modes × 2, SendouQ (today's default)", + teamPick: uniformSettings(["SZ", "TC", "RM", "CB"], 2), + pool: TeamPick.effectivePool( + uniformSettings(["SZ", "TC", "RM", "CB"], 2), + [], + ), + expected: 2, + }, + { + why: "5 modes × 2, all maps", + teamPick: uniformSettings(["TW", "SZ", "TC", "RM", "CB"], 2, "ALL"), + pool: MapPool.ALL, + expected: 2, + }, + { + why: "4 modes × 4, SendouQ", + teamPick: uniformSettings(["SZ", "TC", "RM", "CB"], 4), + pool: TeamPick.effectivePool( + uniformSettings(["SZ", "TC", "RM", "CB"], 4), + [], + ), + expected: 2, + }, + { + why: "custom, 4 modes, same 12 stages each, × 4", + teamPick: uniformSettings(["SZ", "TC", "RM", "CB"], 4, "CUSTOM"), + pool: sameStagesPool(["SZ", "TC", "RM", "CB"], 12), + expected: 2, + }, + { + why: "custom, 4 modes, same 8 stages each, × 4", + teamPick: uniformSettings(["SZ", "TC", "RM", "CB"], 4, "CUSTOM"), + pool: sameStagesPool(["SZ", "TC", "RM", "CB"], 8), + expected: 3, + }, + { + why: "custom, 4 modes, same 6 stages each, × 4 (rule off)", + teamPick: uniformSettings(["SZ", "TC", "RM", "CB"], 4, "CUSTOM"), + pool: sameStagesPool(["SZ", "TC", "RM", "CB"], 6), + expected: 4, + }, + { + why: "custom, 3 modes, same 5 stages each, × 3 (rule off)", + teamPick: uniformSettings(["SZ", "TC", "RM"], 3, "CUSTOM"), + pool: sameStagesPool(["SZ", "TC", "RM"], 5), + expected: 3, + }, + { + why: "SZ × 6 + TC × 2, SendouQ (uneven counts)", + teamPick: { + modes: [ + { mode: "SZ", count: 6 }, + { mode: "TC", count: 2 }, + ], + pool: "SENDOUQ", + } satisfies TeamPickSettings, + pool: TeamPick.effectivePool( + { + modes: [ + { mode: "SZ", count: 6 }, + { mode: "TC", count: 2 }, + ], + pool: "SENDOUQ", + }, + [], + ), + expected: 2, + }, + { + why: "custom, SZ × 6 + TC × 6 + RM × 2, same 8 stages each", + teamPick: { + modes: [ + { mode: "SZ", count: 6 }, + { mode: "TC", count: 6 }, + { mode: "RM", count: 2 }, + ], + pool: "CUSTOM", + } satisfies TeamPickSettings, + pool: sameStagesPool(["SZ", "TC", "RM"], 8), + expected: 3, + }, + ])("$why -> $expected", ({ teamPick, pool, expected }) => { + expect(TeamPick.stageRepeatCap({ teamPick, pool })).toBe(expected); + }); +}); + +describe("TeamPick.poolShortfalls", () => { + test("names every mode with fewer stages than the count plus one", () => { + const teamPick = uniformSettings(["SZ", "TC"], 3, "CUSTOM"); + const pool = new MapPool({ + ...MapPool.EMPTY.parsed, + SZ: stages(4), + TC: stages(3), + }); + + expect(TeamPick.poolShortfalls(teamPick, pool)).toEqual([ + { mode: "TC", required: 4, has: 3 }, + ]); + }); +}); + +describe("TeamPick.maxCount", () => { + test("one less than the pool has, at least 1", () => { + const pool = new MapPool({ ...MapPool.EMPTY.parsed, SZ: stages(5) }); + + expect(TeamPick.maxCount(pool, "SZ")).toBe(4); + expect(TeamPick.maxCount(pool, "TC")).toBe(1); + }); +}); + +describe("TeamPick.validateTeamPool", () => { + const teamPick = uniformSettings(["SZ", "TC"], 2); + const pool = TeamPick.effectivePool(teamPick, []); + const validate = (mapPool: MapPool) => + TeamPick.validateTeamPool({ mapPool, teamPick, pool }); + + test.each([ + { + why: "incomplete picks", + mapPool: new MapPool([{ mode: "SZ", stageId: 1 }]), + expected: "PICKING", + }, + { + why: "complete picks", + mapPool: new MapPool([ + { mode: "SZ", stageId: pool.parsed.SZ[0] }, + { mode: "SZ", stageId: pool.parsed.SZ[1] }, + { mode: "TC", stageId: pool.parsed.TC[0] }, + { mode: "TC", stageId: pool.parsed.TC[1] }, + ]), + expected: "VALID", + }, + { + why: "picks of a mode not in the settings", + mapPool: new MapPool([ + { mode: "SZ", stageId: 1 }, + { mode: "SZ", stageId: 2 }, + { mode: "TC", stageId: 3 }, + { mode: "RM", stageId: 4 }, + ]), + expected: "NOT_IN_POOL", + }, + { + why: "a banned map with the SendouQ pool", + mapPool: new MapPool([ + { mode: "SZ", stageId: 1 }, + { mode: "TC", stageId: SENDOUQ_MAP_POOL.parsed.TC[0] }, + { mode: "TC", stageId: BANNED_MAPS.TC[0] }, + ]), + expected: "NOT_IN_POOL", + }, + { + why: "a stage twice in one mode", + mapPool: new MapPool([ + { mode: "SZ", stageId: 1 }, + { mode: "SZ", stageId: 1 }, + ]), + expected: "STAGE_REPEAT_IN_SAME_MODE", + }, + ])("$why -> $expected", ({ mapPool, expected }) => { + expect(validate(mapPool)).toBe(expected); + }); + + test("a stage in more modes than the cap", () => { + const threeModes = uniformSettings(["SZ", "TC", "RM"], 2); + const threeModesPool = TeamPick.effectivePool(threeModes, []); + const stageId = threeModesPool.parsed.RM.find( + (candidate) => + threeModesPool.parsed.TC.includes(candidate) && + threeModesPool.parsed.SZ.includes(candidate), + )!; + + expect( + TeamPick.validateTeamPool({ + mapPool: new MapPool([ + { mode: "SZ", stageId }, + { mode: "TC", stageId }, + { mode: "RM", stageId }, + ]), + teamPick: threeModes, + pool: threeModesPool, + }), + ).toBe("TOO_MUCH_STAGE_REPEAT"); + }); +}); diff --git a/app/features/tournament/core/TeamPick.ts b/app/features/tournament/core/TeamPick.ts new file mode 100644 index 000000000..4d3112992 --- /dev/null +++ b/app/features/tournament/core/TeamPick.ts @@ -0,0 +1,168 @@ +import type { TeamPickSettings } from "~/db/tables-json"; +import { MapPool } from "~/features/map-list-generator/core/map-pool"; +import { SENDOUQ_MAP_POOL } from "~/features/match-profile/banned-maps"; +import { modesShort } from "~/modules/in-game-lists/modes"; +import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; +import { assertUnreachable } from "~/utils/types"; + +const DEFAULT_COUNT_BY_MODE_COUNT = [6, 4, 3] as const; +const DEFAULT_COUNT_MANY_MODES = 2; + +/** At least this many stage-slots per pick so a tight pool never forces every stage to be used the maximum times. */ +const STAGE_SLOTS_PER_PICK = 1.5; +const MIN_STAGE_REPEAT_CAP = 2; + +export type TeamPoolValidationStatus = + | "PICKING" + | "VALID" + | "TOO_MUCH_STAGE_REPEAT" + | "STAGE_REPEAT_IN_SAME_MODE" + | "NOT_IN_POOL"; + +/** + * How many maps a team picks per mode by default: 6 for one mode, 4 for two, 3 for three and 2 for more. + * + * @example + * defaultCount(1); // 6 + * defaultCount(4); // 2 + */ +export function defaultCount(modeCount: number) { + return DEFAULT_COUNT_BY_MODE_COUNT[modeCount - 1] ?? DEFAULT_COUNT_MANY_MODES; +} + +/** Team pick settings for the given modes with the default count for each and the SendouQ pool. */ +export function defaultSettings(modes: ModeShort[]): TeamPickSettings { + const count = defaultCount(modes.length); + + return { + modes: sortModes(modes).map((mode) => ({ mode, count })), + pool: "SENDOUQ", + }; +} + +/** Modes in the fixed in-game order, duplicates dropped. */ +export function sortModes(modes: ModeShort[]) { + return modesShort.filter((mode) => modes.includes(mode)); +} + +/** Modes teams pick maps for. */ +export function pickedModes(teamPick: TeamPickSettings) { + return teamPick.modes.map((m) => m.mode); +} + +/** + * Pool the teams pick their maps from, limited to the picked modes. `customPool` is the + * organizer's pool, only read for the "CUSTOM" pool option. + */ +export function effectivePool( + teamPick: TeamPickSettings, + customPool: Array<{ mode: ModeShort; stageId: StageId }>, +): MapPool { + const modesIncluded = pickedModes(teamPick); + + const basePool = () => { + switch (teamPick.pool) { + case "SENDOUQ": + return SENDOUQ_MAP_POOL; + case "ALL": + return MapPool.ALL; + case "CUSTOM": + return new MapPool(customPool); + default: + assertUnreachable(teamPick.pool); + } + }; + + return new MapPool( + basePool().stageModePairs.filter((pair) => + modesIncluded.includes(pair.mode), + ), + ); +} + +/** Most maps a team can pick in a mode, one less than the pool has so the random neutral map always has a stage to draw from. */ +export function maxCount(pool: MapPool, mode: ModeShort) { + return Math.max(1, pool.countMapsByMode(mode) - 1); +} + +/** + * Picked modes whose pool has fewer stages than the count plus one, with how many stages they need. + * Empty for a pool the teams can pick from. + */ +export function poolShortfalls(teamPick: TeamPickSettings, pool: MapPool) { + return teamPick.modes.flatMap(({ mode, count }) => { + const required = count + 1; + const has = pool.countMapsByMode(mode); + + return has < required ? [{ mode, required, has }] : []; + }); +} + +/** + * In how many modes one stage may appear in a team's picks. Grows from 2 as the pool gets tight + * relative to the picks and turns off (equals the mode count) when it can't be satisfied. + * + * @example + * // 4 modes × 2 picks from the SendouQ pool (25 distinct stages) + * stageRepeatCap({ teamPick, pool }); // 2 + * // 4 modes × 4 picks from 8 stages + * stageRepeatCap({ teamPick, pool }); // 3 + */ +export function stageRepeatCap({ + teamPick, + pool, +}: { + teamPick: TeamPickSettings; + pool: MapPool; +}) { + const totalPicks = teamPick.modes.reduce((acc, cur) => acc + cur.count, 0); + const distinctStages = new Set(pool.stages).size; + const modeCount = teamPick.modes.length; + + if (distinctStages === 0) return modeCount; + + const cap = Math.ceil((STAGE_SLOTS_PER_PICK * totalPicks) / distinctStages); + + return Math.min(Math.max(cap, MIN_STAGE_REPEAT_CAP), modeCount); +} + +/** + * Validates a team's picks against the tournament's team pick settings and effective pool. + * Statuses with an explanation come before "PICKING" so a mistake shows while still picking. + */ +export function validateTeamPool({ + mapPool, + teamPick, + pool, +}: { + mapPool: MapPool; + teamPick: TeamPickSettings; + pool: MapPool; +}): TeamPoolValidationStatus { + if ( + new MapPool(mapPool.serialized).stageModePairs.length !== + mapPool.stageModePairs.length + ) { + return "STAGE_REPEAT_IN_SAME_MODE"; + } + + if (mapPool.stageModePairs.some((pair) => !pool.has(pair))) { + return "NOT_IN_POOL"; + } + + const cap = stageRepeatCap({ teamPick, pool }); + const stageCounts = new Map(); + for (const stageId of mapPool.stages) { + stageCounts.set(stageId, (stageCounts.get(stageId) ?? 0) + 1); + } + if (Array.from(stageCounts.values()).some((count) => count > cap)) { + return "TOO_MUCH_STAGE_REPEAT"; + } + + const everyModeComplete = teamPick.modes.every( + ({ mode, count }) => mapPool.countMapsByMode(mode) === count, + ); + if (!everyModeComplete) return "PICKING"; + + return "VALID"; +} diff --git a/app/features/tournament/routes/to.$id.rules.tsx b/app/features/tournament/routes/to.$id.rules.tsx index 13b3599ab..4addfd906 100644 --- a/app/features/tournament/routes/to.$id.rules.tsx +++ b/app/features/tournament/routes/to.$id.rules.tsx @@ -9,7 +9,6 @@ import { Section } from "~/components/Section"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; import { mapsPageWithMapPool } from "~/features/map-list-generator/map-list-generator-urls"; import { useTournament } from "~/features/tournament/tournament-context"; -import { modesShort } from "~/modules/in-game-lists/modes"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { navIconUrl } from "~/utils/urls"; import { MarkdownSection } from "../components/MarkdownSection"; @@ -27,13 +26,13 @@ export default function TournamentRulesPage() { return (
    {rules ? {rules} : null} - - + +
    ); } -function CounterPickMapPool() { +function OrganizerMapPool() { const { t } = useTranslation(["calendar"]); const tournament = useTournament(); @@ -56,25 +55,29 @@ function CounterPickMapPool() { ); } -function TiebreakerMapPool() { +function TeamPickRules() { const { t } = useTranslation(["tournament", "game-misc"]); const tournament = useTournament(); + const teamPick = tournament.teamPickSettings; - if (tournament.ctx.tieBreakerMapPool.length === 0) return null; + if (!teamPick) return null; + + const picks = teamPick.modes + .map(({ mode, count }) => `${count}× ${t(`game-misc:MODE_SHORT_${mode}`)}`) + .join(", "); return ( -
    - {t("tournament:rules.tiebreakerMapPool", { - maps: tournament.ctx.tieBreakerMapPool - .sort( - (a, b) => modesShort.indexOf(a.mode) - modesShort.indexOf(b.mode), - ) - .map( - (map) => - `${t(`game-misc:MODE_SHORT_${map.mode}`)} ${t(`game-misc:STAGE_${map.stageId}`)}`, - ) - .join(", "), - })} +
    +
    {t("tournament:rules.teamPick.picks", { picks })}
    +
    {t(`tournament:rules.teamPick.pool.${teamPick.pool}`)}
    +
    {t("tournament:rules.teamPick.neutral")}
    + {teamPick.modes.length > 1 ? ( +
    + {t("tournament:rules.teamPick.stageRepeat", { + cap: tournament.stageRepeatCap, + })} +
    + ) : null}
    ); } diff --git a/app/features/tournament/routes/to.$id.teams.$tid.tsx b/app/features/tournament/routes/to.$id.teams.$tid.tsx index 015326485..6b43806d5 100644 --- a/app/features/tournament/routes/to.$id.teams.$tid.tsx +++ b/app/features/tournament/routes/to.$id.teams.$tid.tsx @@ -316,8 +316,8 @@ function SetInfo({ return t("tournament:pickInfo.both"); case "DEFAULT": return t("tournament:pickInfo.default"); - case "TIEBREAKER": - return t("tournament:pickInfo.tiebreaker"); + case "RANDOM": + return t("tournament:pickInfo.random"); case "COUNTERPICK": { if (mapIndex > 0) { const previousMap = set.maps[mapIndex - 1]; diff --git a/app/features/tournament/tournament-constants.ts b/app/features/tournament/tournament-constants.ts index 3513d7cfa..b639fd783 100644 --- a/app/features/tournament/tournament-constants.ts +++ b/app/features/tournament/tournament-constants.ts @@ -2,9 +2,6 @@ import { TEAM } from "../team/team-constants"; export const TOURNAMENT = { TEAM_NAME_MAX_LENGTH: TEAM.NAME_MAX_LENGTH, - COUNTERPICK_MAPS_PER_MODE: 2, - COUNTERPICK_MAX_STAGE_REPEAT: 2, - COUNTERPICK_ONE_MODE_TOURNAMENT_MAPS_PER_MODE: 6, AVAILABLE_BEST_OF: [1, 3, 5, 7, 9] as const, ENOUGH_TEAMS_TO_START: 2, MAX_BRACKETS_PER_TOURNAMENT: 10, @@ -40,13 +37,12 @@ export const TOURNAMENT_STAGE_TYPES = [ ] as const; /** AUTO = teams pick map pools ahead and each round's map list is made automatically, TO = the TO picks the maps. */ -export type TournamentMapPickingStyle = - | "TO" - | "AUTO_ALL" - | "AUTO_SZ" - | "AUTO_TC" - | "AUTO_RM" - | "AUTO_CB"; +export type TournamentMapPickingStyle = "TO" | "AUTO"; + +/** Where team picked maps come from: SendouQ legal maps, every map or a pool the organizer builds. */ +export const TEAM_PICK_POOLS = ["SENDOUQ", "ALL", "CUSTOM"] as const; + +export type TeamPickPool = (typeof TEAM_PICK_POOLS)[number]; export const TOURNAMENT_STAFF_ROLES = ["ORGANIZER", "STREAMER"] as const; diff --git a/app/features/tournament/tournament-utils.ts b/app/features/tournament/tournament-utils.ts index cac429c33..921b76a08 100644 --- a/app/features/tournament/tournament-utils.ts +++ b/app/features/tournament/tournament-utils.ts @@ -1,41 +1,28 @@ import { sub } from "date-fns"; import * as R from "remeda"; -import type { CastedMatchesInfo } from "~/db/tables-json"; +import type { CastedMatchesInfo, TeamPickSettings } from "~/db/tables-json"; import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes"; -import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; +import type { ModeShort } from "~/modules/in-game-lists/types"; import { databaseTimestampToDate } from "~/utils/dates"; import { SHORT_NANOID_LENGTH } from "~/utils/id"; import type { Tables } from "../../db/tables"; -import { MapPool } from "../map-list-generator/core/map-pool"; -import { BANNED_MAPS } from "../match-profile/banned-maps"; import * as Seasons from "../mmr/core/Seasons"; import type { Bracket as BracketClass } from "../tournament-bracket/core/Bracket"; import type { ParsedBracket } from "../tournament-bracket/core/Progression"; import * as Progression from "../tournament-bracket/core/Progression"; import type { Tournament as TournamentClass } from "../tournament-bracket/core/Tournament"; -import type { TournamentData } from "../tournament-bracket/core/Tournament.server"; -import { TOURNAMENT } from "./tournament-constants"; - -const mapPickingStyleToModeRecord = { - AUTO_SZ: ["SZ"], - AUTO_TC: ["TC"], - AUTO_RM: ["RM"], - AUTO_CB: ["CB"], - AUTO_ALL: rankedModesShort, -} as const; - -export const mapPickingStyleToModes = ( - mapPickingStyle: Exclude, -) => { - return mapPickingStyleToModeRecord[mapPickingStyle].slice(); -}; +import * as TeamPick from "./core/TeamPick"; +/** + * Modes played in a tournament: the team pick modes when teams pick, otherwise the distinct modes + * of the organizer's pool (every ranked mode while the pool is empty). + */ export function modesIncluded( - mapPickingStyle: Tables["Tournament"]["mapPickingStyle"], + teamPick: TeamPickSettings | undefined, toSetMapPool: Array<{ mode: ModeShort }>, ): ModeShort[] { - if (mapPickingStyle !== "TO") { - return mapPickingStyleToModes(mapPickingStyle); + if (teamPick) { + return TeamPick.pickedModes(teamPick); } const pickedModes = R.unique(toSetMapPool.map((map) => map.mode)); @@ -49,90 +36,6 @@ export function modesIncluded( ); } -export function isOneModeTournamentOf( - mapPickingStyle: Tables["Tournament"]["mapPickingStyle"], - toSetMapPool: Array<{ mode: ModeShort }>, -) { - return modesIncluded(mapPickingStyle, toSetMapPool).length === 1 - ? modesIncluded(mapPickingStyle, toSetMapPool)[0] - : null; -} - -export type CounterPickValidationStatus = - | "PICKING" - | "VALID" - | "TOO_MUCH_STAGE_REPEAT" - | "STAGE_REPEAT_IN_SAME_MODE" - | "INCLUDES_BANNED" - | "INCLUDES_TIEBREAKER"; - -export function validateCounterPickMapPool( - mapPool: MapPool, - isOneModeOnlyTournamentFor: ModeShort | null, - tieBreakerMapPool: TournamentData["ctx"]["tieBreakerMapPool"], -): CounterPickValidationStatus { - const stageCounts = new Map(); - for (const stageId of mapPool.stages) { - if (!stageCounts.has(stageId)) { - stageCounts.set(stageId, 0); - } - - if ( - stageCounts.get(stageId)! >= TOURNAMENT.COUNTERPICK_MAX_STAGE_REPEAT || - (isOneModeOnlyTournamentFor && stageCounts.get(stageId)! >= 1) - ) { - return "TOO_MUCH_STAGE_REPEAT"; - } - - stageCounts.set(stageId, stageCounts.get(stageId)! + 1); - } - - if ( - new MapPool(mapPool.serialized).stageModePairs.length !== - mapPool.stageModePairs.length - ) { - return "STAGE_REPEAT_IN_SAME_MODE"; - } - - if ( - mapPool.stageModePairs.some((pair) => - BANNED_MAPS[pair.mode].includes(pair.stageId), - ) - ) { - return "INCLUDES_BANNED"; - } - - if ( - mapPool.stageModePairs.some((pair) => - tieBreakerMapPool.some( - (stage) => stage.mode === pair.mode && stage.stageId === pair.stageId, - ), - ) - ) { - return "INCLUDES_TIEBREAKER"; - } - - if ( - !isOneModeOnlyTournamentFor && - (mapPool.parsed.SZ.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE || - mapPool.parsed.TC.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE || - mapPool.parsed.RM.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE || - mapPool.parsed.CB.length !== TOURNAMENT.COUNTERPICK_MAPS_PER_MODE) - ) { - return "PICKING"; - } - - if ( - isOneModeOnlyTournamentFor && - mapPool.parsed[isOneModeOnlyTournamentFor].length !== - TOURNAMENT.COUNTERPICK_ONE_MODE_TOURNAMENT_MAPS_PER_MODE - ) { - return "PICKING"; - } - - return "VALID"; -} - export function tournamentIsRanked({ isSetAsRanked, startsAt, diff --git a/app/modules/tournament-map-list-generator/balanced-map-list.ts b/app/modules/tournament-map-list-generator/balanced-map-list.ts index f5ab1378e..af236c75d 100644 --- a/app/modules/tournament-map-list-generator/balanced-map-list.ts +++ b/app/modules/tournament-map-list-generator/balanced-map-list.ts @@ -1,20 +1,28 @@ -import { stageIds } from "~/modules/in-game-lists/stage-ids"; import { invariant } from "~/utils/invariant"; import { logger } from "~/utils/logger"; import { seededRandom } from "~/utils/random"; import { err, ok, type Result } from "~/utils/result"; import type { ModeShort, StageId } from "../in-game-lists/types"; -import { DEFAULT_MAP_POOL } from "./constants"; import type { TournamentMapListMap, TournamentMaplistInput, TournamentMaplistSource, } from "./types"; -type ModeWithStageAndScore = TournamentMapListMap & { score: number }; +type ModeWithStageAndScore = TournamentMapListMap & { + score: number; + /** A pool map rather than a team's pick, meant for the last slot. */ + isNeutral?: boolean; + /** A neutral map some team picked, used only when the pool has nothing neither team picked in the mode. */ + isFallback?: boolean; +}; const OPTIMAL_MAPLIST_SCORE = 0; const MAX_RECURSION_DEPTH = 5_000; +const UNFAIR_PENALTY = 100; +const FALLBACK_NEUTRAL_MAP_PENALTY = 5; +/** Below the bad last map penalty (1) so a common map still decides the match when a fixed mode order runs the picks dry. */ +const EARLY_NEUTRAL_MAP_PENALTY = 0.5; type MapListGenerationError = | "MAX_RECURSION_DEPTH_EXCEEDED" @@ -53,14 +61,17 @@ function generateWithInput( const { seededShuffle } = seededRandom(input.seed); // least recently played first so a good enough list is found before the recursion depth cap - const stages = seededShuffle(resolveCommonStages()).sort( + const stages = seededShuffle(resolveTeamStages()).sort( (a, b) => recencyPenalty(a) - recencyPenalty(b), ); - const mapList: Array = []; + const neutralStages = seededShuffle(resolveNeutralStages()).sort( + (a, b) => recencyPenalty(a) - recencyPenalty(b), + ); + const mapList: Array = []; const bestMapList: { maps?: Array; score: number } = { score: Number.POSITIVE_INFINITY, }; - const usedStages = new Set(); + const usedStages = new Set(); let depth = 0; const backtrack = (): boolean => { @@ -79,28 +90,22 @@ function generateWithInput( return true; } - const stageList = - mapList.length < input.count - 1 || input.tiebreakerMaps.length === 0 - ? resolveStages() - : input.tiebreakerMaps.stageModePairs.map((p) => ({ - ...p, - score: 0, - source: "TIEBREAKER" as const, - })); + // a fixed mode can run the teams' picks dry before the last slot, the pool covers the rest + const stageList = isNeutralSlot() + ? [...stages.filter((stage) => stage.score === 0), ...neutralStages] + : fixedModeOfSlot(mapList.length) + ? [...stages, ...neutralStages] + : stages; - // tiebreaker/fallback lists get their own key range so indices don't collide with the main list - const usedStageKeyOffset = stageList === stages ? 0 : stages.length; - - for (const [i, stage] of stageList.entries()) { - const usedStageKey = i + usedStageKeyOffset; - if (!stageIsOk(stage, usedStageKey)) continue; + for (const stage of stageList) { + if (!stageIsOk(stage)) continue; mapList.push(stage); - usedStages.add(usedStageKey); + usedStages.add(stageKey(stage)); const continueSearch = backtrack(); if (!continueSearch) return false; - usedStages.delete(usedStageKey); + usedStages.delete(stageKey(stage)); mapList.pop(); } @@ -110,21 +115,48 @@ function generateWithInput( const searchExhausted = backtrack(); // a list found before the depth cap is valid, only its optimality is unproven - if (bestMapList.maps) return ok(bestMapList.maps); + if (bestMapList.maps) { + if (bestMapList.maps.some((map) => map.isFallback)) { + logger.warn( + `Neutral map fallback: both teams together picked every pool map of the mode. Team IDs: ${input.teams.map((t) => t.id).join(", ")}`, + ); + } + + return ok( + bestMapList.maps.map( + ({ score: _score, isNeutral: _neutral, isFallback: _fb, ...map }) => + map, + ), + ); + } if (!searchExhausted) return err("MAX_RECURSION_DEPTH_EXCEEDED"); return err("COULD_NOT_GENERATE_MAPLIST"); - function resolveCommonStages() { + function stageKey(stage: { mode: ModeShort; stageId: StageId }) { + return `${stage.mode}-${stage.stageId}`; + } + + /** Both teams' picks scored per team, or the whole pool when neither picked. */ + function resolveTeamStages(): Array { + if (neitherTeamSubmitted()) { + return poolMaps().map((pair) => ({ + ...pair, + score: 0, + source: "RANDOM" as const, + })); + } + const sorted = input.teams .slice() .sort((a, b) => a.id - b.id) as TournamentMaplistInput["teams"]; - const result = sorted[0].maps.stageModePairs.map((pair) => ({ - ...pair, - score: 1, - source: sorted[0].id as TournamentMaplistSource, - })); + const result: Array = + sorted[0].maps.stageModePairs.map((pair) => ({ + ...pair, + score: 1, + source: sorted[0].id as TournamentMaplistSource, + })); for (const stage of sorted[1].maps.stageModePairs) { const alreadyIncludedStage = result.find( @@ -140,65 +172,46 @@ function generateWithInput( } } - if ( - input.teams[0].maps.stages.length === 0 && - input.teams[1].maps.stages.length === 0 - ) { - // neither team submitted map, we go default - result.push( - ...getDefaultMapPool().map((pair) => ({ - ...pair, - score: 0, - source: "DEFAULT" as const, - })), - ); - } else if ( - input.teams[0].maps.stages.length === 0 || - input.teams[1].maps.stages.length === 0 - ) { - // if one team didn't submit, the list can consist of only the other team's stages + // if one team didn't submit, the list can consist of only the other team's stages + if (!bothTeamsSubmitted()) { for (const stageObj of result) { stageObj.score = 0; } } - return result.sort((a, b) => - `${a.stageId}-${a.mode}`.localeCompare(`${b.stageId}-${b.mode}`), - ); + return result.sort((a, b) => stageKey(a).localeCompare(stageKey(b))); } - function resolveStages() { - if (utilizeOtherStageIdsWhenNoTiebreaker()) { - // no overlap so we need to use a random map for tiebreaker + /** + * Random neutral map candidates: pool maps neither team picked in that mode. A mode where the + * teams together picked the whole pool falls back to every pool map of the mode. + */ + function resolveNeutralStages(): Array { + if (neitherTeamSubmitted()) return []; - if (tournamentIsOneModeOnly()) { - return seededShuffle([...stageIds]) - .filter( - (stageId) => - !input.teams[0].maps.hasStage(stageId) && - !input.teams[1].maps.hasStage(stageId), - ) - .map((stageId) => ({ - stageId, - mode: input.modesIncluded[0], - score: 0, - source: "TIEBREAKER" as const, - })); - } - return DEFAULT_MAP_POOL.stageModePairs - .filter( - (pair) => - !input.teams[0].maps.has(pair) && !input.teams[1].maps.has(pair), - ) - .map((pair) => ({ - stageId: pair.stageId, - mode: pair.mode, - score: 0, - source: "TIEBREAKER" as const, - })); - } + const pool = poolMaps(); + const pickedByATeam = (pair: { mode: ModeShort; stageId: StageId }) => + input.teams.some((team) => team.maps.has(pair)); - return stages; + return input.modesIncluded.flatMap((mode) => { + const ofMode = pool.filter((pair) => pair.mode === mode); + const neitherPicked = ofMode.filter((pair) => !pickedByATeam(pair)); + const candidates = neitherPicked.length > 0 ? neitherPicked : ofMode; + + return candidates.map((pair) => ({ + ...pair, + score: 0, + source: "RANDOM" as const, + isNeutral: true, + isFallback: neitherPicked.length === 0, + })); + }); + } + + function poolMaps() { + return input.pool.stageModePairs + .filter((pair) => input.modesIncluded.includes(pair.mode)) + .sort((a, b) => stageKey(a).localeCompare(stageKey(b))); } function validateInput( @@ -212,9 +225,7 @@ function generateWithInput( if (!everyMapIsOfIncludedMode) return "MAPS_FOR_MODES_NOT_INCLUDED"; for (const team of args.teams) { - const stringified = team.maps.stageModePairs.map( - (p) => `${p.stageId}-${p.mode}`, - ); + const stringified = team.maps.stageModePairs.map(stageKey); const unique = new Set(stringified); if (unique.size !== stringified.length) { return "DUPLICATE_MAPS_IN_MAP_POOL"; @@ -224,39 +235,36 @@ function generateWithInput( return null; } - function utilizeOtherStageIdsWhenNoTiebreaker() { - if (mapList.length < input.count - 1) return false; - - if ( - input.teams.every((team) => !team.maps.isEmpty()) && - !input.teams[0].maps.overlaps(input.teams[1].maps) - ) { - return true; - } - - const teamsMapsLeftNotPicked = [ - ...input.teams[0].maps, - ...input.teams[1].maps, - ].some( - (stage) => - !mapList.some( - (map) => map.stageId === stage.stageId && map.mode === stage.mode, - ), - ); - if (!teamsMapsLeftNotPicked) return true; - - return false; + function bothTeamsSubmitted() { + return input.teams.every((team) => !team.maps.isEmpty()); } - function getDefaultMapPool() { - if (tournamentIsOneModeOnly()) { - const mode = input.modesIncluded[0]; + function neitherTeamSubmitted() { + return input.teams.every((team) => team.maps.isEmpty()); + } - return stageIds.map((id) => ({ mode, stageId: id })); - } + /** The last slot, decided by a map both teams picked or a random one from the pool. */ + function isNeutralSlot() { + return mapList.length === input.count - 1; + } - return DEFAULT_MAP_POOL.stageModePairs.filter(({ mode }) => - input.modesIncluded.includes(mode), + /** Mode fixed for the slot by the mode order, if any. */ + function fixedModeOfSlot(index: number) { + if (!input.modeOrder || input.modeOrder.length === 0) return null; + + return input.modeOrder[index % input.modeOrder.length]; + } + + /** Maps both teams picked that could decide the match in the last slot. */ + function commonMapsForNeutralSlot() { + if (!bothTeamsSubmitted()) return []; + + const lastSlotMode = fixedModeOfSlot(input.count - 1); + + return input.teams[0].maps.stageModePairs.filter( + (pair) => + input.teams[1].maps.has(pair) && + (lastSlotMode === null || pair.mode === lastSlotMode), ); } @@ -266,15 +274,16 @@ function generateWithInput( >; // rules here both shape the generated list and prune subtrees from the search - function stageIsOk(stage: StageValidatorInput, index: number) { - if (usedStages.has(index)) return false; + function stageIsOk(stage: StageValidatorInput) { + if (usedStages.has(stageKey(stage))) return false; if (mapListAlreadyFull()) return false; + if (isNotFollowingModeOrder(stage)) return false; if (isEarlyModeRepeat(stage)) return false; if (isNotFollowingModePattern(stage)) return false; if (isMakingThingsUnfair(stage)) return false; if (isStageRepeatWithoutBreak(stage)) return false; if (isSecondPickBySameTeamInRow(stage)) return false; - if (wouldPreventTiebreaker(stage)) return false; + if (wouldUseUpCommonMaps(stage)) return false; return true; } @@ -287,29 +296,26 @@ function generateWithInput( return mapList.length === input.count; } + function isNotFollowingModeOrder(stage: StageValidatorInput) { + const fixedMode = fixedModeOfSlot(mapList.length); + if (!fixedMode) return false; + + return stage.mode !== fixedMode; + } + function isEarlyModeRepeat(stage: StageValidatorInput) { - if (tournamentIsOneModeOnly()) return false; + if (tournamentIsOneModeOnly() || input.modeOrder) return false; // all modes already appeared if (mapList.length >= input.modesIncluded.length) return false; - if ( - mapList.some( - (alreadyIncludedStage) => alreadyIncludedStage.mode === stage.mode, - ) - ) { - return true; - } - - return false; + return mapList.some( + (alreadyIncludedStage) => alreadyIncludedStage.mode === stage.mode, + ); } function isNotFollowingModePattern(stage: StageValidatorInput) { - if (tournamentIsOneModeOnly()) return false; - - if (input.followModeOrder) { - return isNotFollowingModeOrder(stage); - } + if (tournamentIsOneModeOnly() || input.modeOrder) return false; // not all modes appeared yet if (mapList.length < input.modesIncluded.length) return false; @@ -329,21 +335,11 @@ function generateWithInput( return mapList[mapList.length - 1].mode !== previousModeShouldBe; } - function isNotFollowingModeOrder(stage: StageValidatorInput) { - let currentIndex = 0; - for (const _ of mapList) { - currentIndex++; - if (currentIndex === input.modesIncluded.length) currentIndex = 0; - } - - return stage.mode !== input.modesIncluded[currentIndex]; - } - // don't allow making two picks from one team in row function isMakingThingsUnfair(stage: StageValidatorInput) { - // e.g. Bo5 with 100% overlap in one mode only: overlap, T1, T2, T1, TIEBREAKER must be allowed; + // e.g. Bo5 with 100% overlap in one mode only: overlap, T1, T2, T1, RANDOM must be allowed; // scoring still prefers better options - if (stage.source === "TIEBREAKER") return false; + if (stage.source === "RANDOM") return false; const score = mapList.reduce((acc, cur) => acc + cur.score, 0); const newScore = score + stage.score; @@ -369,34 +365,27 @@ function generateWithInput( return lastStage.score === stage.score; } - function wouldPreventTiebreaker(stage: StageValidatorInput) { - // tiebreaker always guaranteed if maps are explicitly set - if (input.tiebreakerMaps.length > 0) return false; + /** A map both teams picked is reserved for the last slot when one exists. */ + function wouldUseUpCommonMaps(stage: StageValidatorInput) { + const commonMaps = commonMapsForNeutralSlot(); + if (commonMaps.length === 0) return false; - const commonMaps = input.teams[0].maps.stageModePairs.filter( - ({ stageId, mode }) => - input.teams[1].maps.stageModePairs.some( - (pair) => pair.stageId === stageId && pair.mode === mode, - ), - ); + // both teams having identical pools + if (commonMaps.length === input.teams[0].maps.stageModePairs.length) { + return false; + } const newMapList = [...mapList, stage]; + if (newMapList.length === input.count) return false; - const newCommonMaps = commonMaps.filter( + const commonMapsLeft = commonMaps.filter( ({ stageId, mode }) => !newMapList.some( (pair) => pair.stageId === stageId && pair.mode === mode, ), ); - // a common map for the tiebreaker existed but got picked too early - return ( - commonMaps.length > 0 && - // both teams having identical pools - commonMaps.length !== input.teams[0].maps.stageModePairs.length && - newCommonMaps.length === 0 && - newMapList.length !== input.count - ); + return commonMapsLeft.length === 0; } function rateMapList() { @@ -416,17 +405,21 @@ function generateWithInput( appearedMaps.set(stage.stageId, timesAppeared + 1); } - if (!lastMapIsAGoodTieBreaker()) { + if (!lastMapIsAGoodNeutralMap()) { score += 1; } const fairnessBalance = mapList.reduce((acc, cur) => acc + cur.score, 0); if (fairnessBalance !== 0) { - score += 100; + score += UNFAIR_PENALTY; } - for (const map of mapList) { + for (const [i, map] of mapList.entries()) { score += recencyPenalty(map); + if (map.isFallback) score += FALLBACK_NEUTRAL_MAP_PENALTY; + if (map.isNeutral && i !== mapList.length - 1) { + score += EARLY_NEUTRAL_MAP_PENALTY; + } } return score; @@ -443,29 +436,10 @@ function generateWithInput( return Math.max(10 - Math.floor(recentIndex / 2) * 2, 0); } - function lastMapIsAGoodTieBreaker() { - // guaranteed to be good if more than one mode - if (!tournamentIsOneModeOnly()) return true; + /** A map both teams picked decides the match whenever there is one that could. */ + function lastMapIsAGoodNeutralMap() { + if (commonMapsForNeutralSlot().length === 0) return true; - // specifically made tiebreaker map is considered good - const last = mapList[mapList.length - 1]; - if (last.source === "TIEBREAKER") return true; - - // we can't have a map from pools of both teams if both didn't submit maps - if (input.teams.some((team) => team.maps.stageModePairs.length === 0)) { - return true; - } - - const tieBreakerMap = mapList[mapList.length - 1]; - - let appearanceCount = 0; - - for (const team of input.teams) { - for (const stage of team.maps.stages) { - if (stage === tieBreakerMap.stageId) appearanceCount++; - } - } - - return appearanceCount === 2; + return mapList[mapList.length - 1].source === "BOTH"; } } diff --git a/app/modules/tournament-map-list-generator/constants.ts b/app/modules/tournament-map-list-generator/constants.ts index 72787e1d1..0359ac63a 100644 --- a/app/modules/tournament-map-list-generator/constants.ts +++ b/app/modules/tournament-map-list-generator/constants.ts @@ -1,37 +1,8 @@ -import { MapPool } from "~/features/map-list-generator/core/map-pool"; - export const sourceTypes = [ "DEFAULT", - "TIEBREAKER", + "RANDOM", "BOTH", "TO", "COUNTERPICK", "ROLL", ] as const; - -/** Only used as a fallback, in the case that map list generation has a bug */ -export const DEFAULT_MAP_POOL = new MapPool([ - { mode: "SZ", stageId: 6 }, - { mode: "SZ", stageId: 8 }, - { mode: "SZ", stageId: 9 }, - { mode: "SZ", stageId: 15 }, - { mode: "SZ", stageId: 17 }, - - { mode: "TC", stageId: 1 }, - { mode: "TC", stageId: 2 }, - { mode: "TC", stageId: 10 }, - { mode: "TC", stageId: 14 }, - { mode: "TC", stageId: 16 }, - - { mode: "RM", stageId: 0 }, - { mode: "RM", stageId: 3 }, - { mode: "RM", stageId: 9 }, - { mode: "RM", stageId: 10 }, - { mode: "RM", stageId: 17 }, - - { mode: "CB", stageId: 0 }, - { mode: "CB", stageId: 1 }, - { mode: "CB", stageId: 8 }, - { mode: "CB", stageId: 14 }, - { mode: "CB", stageId: 16 }, -]); diff --git a/app/modules/tournament-map-list-generator/generation.test.ts b/app/modules/tournament-map-list-generator/generation.test.ts index 55a84fbc0..189b506f9 100644 --- a/app/modules/tournament-map-list-generator/generation.test.ts +++ b/app/modules/tournament-map-list-generator/generation.test.ts @@ -1,10 +1,11 @@ import { describe, expect, test } from "vitest"; import { MapPool } from "~/features/map-list-generator/core/map-pool"; +import { SENDOUQ_MAP_POOL } from "~/features/match-profile/banned-maps"; import { unwrap, unwrapErr } from "~/utils/result"; import { rankedModesShort } from "../in-game-lists/modes"; import type { RankedModeShort } from "../in-game-lists/types"; import { generateBalancedMapList } from "./balanced-map-list"; -import { DEFAULT_MAP_POOL } from "./constants"; +import { starterMap } from "./starter-map"; import type { TournamentMaplistInput } from "./types"; const team1Picks = new MapPool([ @@ -37,12 +38,6 @@ const team2PicksNoOverlap = new MapPool([ { mode: "CB", stageId: 2 }, { mode: "CB", stageId: 3 }, ]); -const tiebreakerPicks = new MapPool([ - { mode: "SZ", stageId: 1 }, - { mode: "TC", stageId: 11 }, - { mode: "RM", stageId: 3 }, - { mode: "CB", stageId: 4 }, -]); const duplicationPicks = new MapPool([ { mode: "SZ", stageId: 4 }, @@ -54,12 +49,6 @@ const duplicationPicks = new MapPool([ { mode: "CB", stageId: 6 }, { mode: "CB", stageId: 7 }, ]); -const duplicationTiebreaker = new MapPool([ - { mode: "SZ", stageId: 7 }, - { mode: "TC", stageId: 6 }, - { mode: "RM", stageId: 5 }, - { mode: "CB", stageId: 4 }, -]); const generateMapsResult = ({ count = 5, @@ -74,18 +63,18 @@ const generateMapsResult = ({ maps: team2Picks, }, ], - tiebreakerMaps = tiebreakerPicks, + pool = SENDOUQ_MAP_POOL, modesIncluded = [...rankedModesShort], - followModeOrder = false, + modeOrder, recentlyPlayedMaps, }: Partial = {}) => { return generateBalancedMapList({ count, seed, teams, - tiebreakerMaps, + pool, modesIncluded, - followModeOrder, + modeOrder, recentlyPlayedMaps, }); }; @@ -93,6 +82,16 @@ const generateMapsResult = ({ const generateMaps = (args: Partial = {}) => unwrap(generateMapsResult(args)); +const pickedByEitherTeam = ( + map: { mode: string; stageId: number }, + teams: MapPool[], +) => + teams.some((team) => + team.stageModePairs.some( + (pair) => pair.mode === map.mode && pair.stageId === map.stageId, + ), + ); + describe("Tournament map list generator", () => { test("Modes are spread evenly", () => { const mapList = generateMaps(); @@ -111,39 +110,61 @@ describe("Tournament map list generator", () => { } }); - test("Follow mode order option", () => { - const mapList = generateMaps({ followModeOrder: true }); + test("Follows the mode order when given", () => { + const mapList = generateMaps({ + modeOrder: ["SZ", "TC", "RM", "CB", "SZ"], + }); - expect(mapList[0].mode).toBe("SZ"); - expect(mapList[1].mode).toBe("TC"); - expect(mapList[2].mode).toBe("RM"); - expect(mapList[3].mode).toBe("CB"); - expect(mapList[4].mode).toBe("SZ"); + expect(mapList.map((map) => map.mode)).toEqual([ + "SZ", + "TC", + "RM", + "CB", + "SZ", + ]); + }); + + test("Mode order can repeat a mode early", () => { + const mapList = generateMaps({ + modeOrder: ["SZ", "TC", "SZ", "RM", "SZ"], + teams: [ + { id: 1, maps: team1Picks }, + { id: 2, maps: team2PicksNoOverlap }, + ], + }); + + expect(mapList.map((map) => map.mode)).toEqual([ + "SZ", + "TC", + "SZ", + "RM", + "SZ", + ]); }); test("Equal picks", () => { let our = 0; let their = 0; - let tiebreaker = 0; - const mapList = generateMaps(); + const mapList = generateMaps({ + teams: [ + { id: 1, maps: team1Picks }, + { id: 2, maps: team2PicksNoOverlap }, + ], + }); for (const { stageId, mode } of mapList) { if (team1Picks.has({ stageId, mode })) { our++; } - if (team2Picks.has({ stageId, mode })) { + if (team2PicksNoOverlap.has({ stageId, mode })) { their++; } - - if (tiebreakerPicks.has({ stageId, mode })) { - tiebreaker++; - } } expect(our).toBe(their); - expect(tiebreaker).toBe(1); + expect(mapList[4].source).toBe("RANDOM"); }); test("No stage repeats in optimal case", () => { @@ -249,7 +270,7 @@ describe("Tournament map list generator", () => { } }); - test("Creates map list even if neither team submitted maps", () => { + test("Creates map list from the pool if neither team submitted maps", () => { const mapList = generateMaps({ teams: [ { @@ -264,6 +285,10 @@ describe("Tournament map list generator", () => { }); expect(mapList.length).toBe(5); + for (const map of mapList) { + expect(map.source).toBe("RANDOM"); + expect(SENDOUQ_MAP_POOL.has(map)).toBe(true); + } }); test("Handles worst case with duplication", () => { @@ -279,15 +304,10 @@ describe("Tournament map list generator", () => { }, ], count: 7, - tiebreakerMaps: duplicationTiebreaker, }); expect(maplist.length).toBe(7); - // all stages appear - const stages = new Set(maplist.map(({ stageId }) => stageId)); - expect(stages.size).toBe(4); - // no consecutive stage replays for (let i = 0; i < maplist.length - 1; i++) { expect(maplist[i].stageId).not.toBe(maplist[i + 1].stageId); @@ -361,7 +381,7 @@ describe("Tournament map list generator", () => { } }); - test("Calculates all mode maps without tiebreaker", () => { + test("Map both teams picked decides the match", () => { const mapList = generateMaps({ teams: [ { @@ -374,15 +394,15 @@ describe("Tournament map list generator", () => { }, ], count: 7, - tiebreakerMaps: new MapPool([]), }); // the one map both of them picked expect(mapList[6].stageId).toBe(7); expect(mapList[6].mode).toBe("RM"); + expect(mapList[6].source).toBe("BOTH"); }); - test("Calculates all mode maps without tiebreaker (no overlap)", () => { + test("Random pool map neither team picked decides the match without overlap", () => { const mapList = generateMaps({ teams: [ { @@ -395,43 +415,136 @@ describe("Tournament map list generator", () => { }, ], count: 7, - tiebreakerMaps: new MapPool([]), }); - // default map pool contains the tiebreaker - expect( - DEFAULT_MAP_POOL.stageModePairs.some( - (pair) => - pair.stageId === mapList[6].stageId && pair.mode === mapList[6].mode, - ), - ).toBe(true); + const last = mapList[6]; - // neither teams map pool contains the tiebreaker - expect( - team1Picks.stageModePairs.some( - (pair) => - pair.stageId === mapList[6].stageId && pair.mode === mapList[6].mode, - ), - ).toBe(false); - expect( - team2PicksNoOverlap.stageModePairs.some( - (pair) => - pair.stageId === mapList[6].stageId && pair.mode === mapList[6].mode, - ), - ).toBe(false); + expect(last.source).toBe("RANDOM"); + expect(SENDOUQ_MAP_POOL.has(last)).toBe(true); + expect(pickedByEitherTeam(last, [team1Picks, team2PicksNoOverlap])).toBe( + false, + ); + }); + + test("Random neutral map is drawn from the tournament's pool", () => { + const pool = new MapPool({ + SZ: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + TC: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + RM: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + CB: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + TW: [], + }); + + for (let i = 1; i <= 10; i++) { + const mapList = generateMaps({ + seed: String(i), + teams: [ + { id: 1, maps: team1Picks }, + { id: 2, maps: team2PicksNoOverlap }, + ], + pool, + }); + + const last = mapList[4]; + expect(last.source).toBe("RANDOM"); + expect(pool.has(last)).toBe(true); + } + }); + + test("Random neutral map considers stages picked in other modes free", () => { + // TC pool: every stage some team picked in another mode, only 8 unpicked in TC + const pool = new MapPool({ + SZ: [4, 5, 9, 11], + TC: [4, 5, 7, 8, 9, 10, 11, 1, 2, 3], + RM: [], + CB: [], + TW: [], + }); + const team1 = new MapPool({ + SZ: [4, 5], + TC: [1, 2], + RM: [], + CB: [], + TW: [], + }); + const team2 = new MapPool({ + SZ: [9, 11], + TC: [3, 7], + RM: [], + CB: [], + TW: [], + }); + + const mapList = generateMaps({ + count: 3, + teams: [ + { id: 1, maps: team1 }, + { id: 2, maps: team2 }, + ], + pool, + modesIncluded: ["SZ", "TC"], + modeOrder: ["SZ", "TC", "TC"], + }); + + const last = mapList[2]; + expect(last.mode).toBe("TC"); + expect(last.source).toBe("RANDOM"); + expect([4, 5, 8, 9, 10, 11]).toContain(last.stageId); + }); + + test("Falls back to a pool map a team picked when the mode has nothing left", () => { + const pool = new MapPool({ + SZ: [1, 2, 3, 4], + TW: [], + TC: [], + RM: [], + CB: [], + }); + + const mapList = generateMaps({ + count: 3, + teams: [ + { id: 1, maps: new MapPool({ ...MapPool.EMPTY.parsed, SZ: [1, 2] }) }, + { id: 2, maps: new MapPool({ ...MapPool.EMPTY.parsed, SZ: [3, 4] }) }, + ], + pool, + modesIncluded: ["SZ"], + }); + + expect(mapList.length).toBe(3); + expect(mapList[2].source).toBe("RANDOM"); + expect(pool.has(mapList[2])).toBe(true); + }); + + test("Mode order repeating a mode draws pool maps neither team picked before the last slot", () => { + const pool = new MapPool({ ...MapPool.EMPTY.parsed, TC: [1, 2, 3] }); + const commonPick = new MapPool({ ...MapPool.EMPTY.parsed, TC: [1] }); + + const result = generateMapsResult({ + count: 3, + teams: [ + { id: 1, maps: commonPick }, + { id: 2, maps: commonPick }, + ], + pool, + modesIncluded: ["TC"], + modeOrder: ["TC", "TC", "TC"], + }); + + expect(result.ok).toBe(true); + const mapList = unwrap(result); + + expect(mapList.map((map) => map.mode)).toEqual(["TC", "TC", "TC"]); + expect(new Set(mapList.map((map) => map.stageId)).size).toBe(3); + expect(mapList.every((map) => pool.has(map))).toBe(true); + expect(mapList[2]).toMatchObject({ stageId: 1, source: "BOTH" }); }); const threeModesArgs: TournamentMaplistInput = { count: 7, seed: "1002", modesIncluded: ["TC", "TW", "RM"], - tiebreakerMaps: new MapPool({ - TW: [], - SZ: [], - TC: [], - RM: [], - CB: [], - }), + pool: MapPool.ALL, teams: [ { id: 1002, @@ -466,7 +579,6 @@ describe("Tournament map list generator", () => { test("handles 100% overlap in one mode and none in others", () => { generateMaps({ count: 5, - followModeOrder: false, modesIncluded: ["SZ", "TC", "RM", "CB"], seed: "4866", teams: [ @@ -545,26 +657,38 @@ describe("Tournament map list generator", () => { ]), }, ], - tiebreakerMaps: new MapPool([ - { - stageId: 15, - mode: "SZ", - }, - { - stageId: 0, - mode: "CB", - }, - { - stageId: 16, - mode: "RM", - }, - { - stageId: 8, - mode: "TC", - }, - ]), }); }); + + test("Uneven counts per mode", () => { + const team1 = new MapPool({ + SZ: [0, 1, 2, 3, 4, 5], + TC: [6, 7], + RM: [], + CB: [], + TW: [], + }); + const team2 = new MapPool({ + SZ: [6, 7, 8, 9, 10, 11], + TC: [12, 13], + RM: [], + CB: [], + TW: [], + }); + + const mapList = generateMaps({ + count: 5, + teams: [ + { id: 1, maps: team1 }, + { id: 2, maps: team2 }, + ], + modesIncluded: ["SZ", "TC"], + }); + + expect(mapList.length).toBe(5); + expect(mapList.some((map) => map.mode === "TC")).toBe(true); + expect(mapList.some((map) => map.mode === "SZ")).toBe(true); + }); }); const team1SZPicks = new MapPool([ @@ -606,7 +730,6 @@ describe("TournamentMapListGeneratorOneMode", () => { }, ], modesIncluded: ["SZ"], - tiebreakerMaps: new MapPool([]), }); for (let i = 0; i < mapList.length - 1; i++) { expect(mapList[i].mode).toBe("SZ"); @@ -626,7 +749,6 @@ describe("TournamentMapListGeneratorOneMode", () => { }, ], modesIncluded: ["SZ"], - tiebreakerMaps: new MapPool([]), }); for (let i = 0; i < mapList.length - 1; i++) { expect(mapList[i].mode).toBe("SZ"); @@ -646,14 +768,13 @@ describe("TournamentMapListGeneratorOneMode", () => { }, ], modesIncluded: ["SZ"], - tiebreakerMaps: new MapPool([]), }); const stages = new Set(mapList.map(({ stageId }) => stageId)); expect(stages.size).toBe(5); }); - test("Tiebreaker is always from the maps of the teams when possible", () => { + test("Neutral map is always from the maps of the teams when possible", () => { for (let i = 1; i <= 10; i++) { const mapList = generateMaps({ teams: [ @@ -668,17 +789,17 @@ describe("TournamentMapListGeneratorOneMode", () => { ], modesIncluded: ["SZ"], seed: String(i), - tiebreakerMaps: new MapPool([]), }); const last = mapList[mapList.length - 1]; expect(last?.mode).toBe("SZ"); expect(last?.stageId).toBe(9); + expect(last?.source).toBe("BOTH"); } }); - test("Tiebreaker is from neither team's pool if no overlap", () => { + test("Neutral map is from neither team's pool if no overlap", () => { const mapList = generateMaps({ teams: [ { @@ -691,11 +812,11 @@ describe("TournamentMapListGeneratorOneMode", () => { }, ], modesIncluded: ["SZ"], - tiebreakerMaps: new MapPool([]), }); const last = mapList[mapList.length - 1]; + expect(last.source).toBe("RANDOM"); expect( team1SZPicks.stageModePairs.some( ({ stageId }) => stageId === last?.stageId, @@ -721,13 +842,12 @@ describe("TournamentMapListGeneratorOneMode", () => { }, ], modesIncluded: ["SZ"], - tiebreakerMaps: new MapPool([]), count: 7, }); for (const [i, stage] of mapList.entries()) { if (i === 6) { - expect(stage?.source).toBe("TIEBREAKER"); + expect(stage?.source).toBe("RANDOM"); } else { expect(stage?.source).toBe("BOTH"); } @@ -747,7 +867,6 @@ describe("TournamentMapListGeneratorOneMode", () => { }, ], modesIncluded: ["SZ"], - tiebreakerMaps: new MapPool([]), }); for (const stage of mapList) { @@ -822,7 +941,6 @@ describe("Recently played maps", () => { { id: 1, maps: team1Pool }, { id: 2, maps: team2Pool }, ], - tiebreakerMaps: new MapPool([]), modesIncluded: ["SZ"], recentlyPlayedMaps, }); @@ -906,24 +1024,69 @@ describe("Recently played maps", () => { }); }); -describe("Tiebreaker maps vs picked maps index collision", () => { - test("generates a full map list when team picks and tiebreakers share list indices", () => { - const result = generateMapsResult({ - count: 3, +describe("starterMap", () => { + const args = { + seed: "starter", + modesIncluded: [...rankedModesShort], + pool: SENDOUQ_MAP_POOL, + }; + + test("uses a map both teams picked", () => { + const [map] = starterMap({ + ...args, teams: [ - { id: 1, maps: new MapPool([{ mode: "SZ", stageId: 4 }]) }, - { id: 2, maps: new MapPool([{ mode: "SZ", stageId: 5 }]) }, + { id: 1, maps: team1Picks }, + { id: 2, maps: team2Picks }, ], - tiebreakerMaps: new MapPool([ - { mode: "SZ", stageId: 6 }, - { mode: "SZ", stageId: 7 }, - ]), - modesIncluded: ["SZ"], }); - const mapList = unwrap(result); + expect(map).toEqual({ mode: "RM", stageId: 7, source: "BOTH" }); + }); - expect(mapList.length).toBe(3); - expect(mapList[2].source).toBe("TIEBREAKER"); + test("draws a pool map neither team picked without overlap", () => { + const [map] = starterMap({ + ...args, + teams: [ + { id: 1, maps: team1Picks }, + { id: 2, maps: team2PicksNoOverlap }, + ], + }); + + expect(map.source).toBe("RANDOM"); + expect(SENDOUQ_MAP_POOL.has(map)).toBe(true); + expect(pickedByEitherTeam(map, [team1Picks, team2PicksNoOverlap])).toBe( + false, + ); + }); + + test("respects the mode order over a common map of another mode", () => { + const [map] = starterMap({ + ...args, + modeOrder: ["SZ"], + teams: [ + { id: 1, maps: team1Picks }, + { id: 2, maps: team2Picks }, + ], + }); + + expect(map.mode).toBe("SZ"); + expect(map.source).toBe("RANDOM"); + }); + + test("falls back to a picked pool map when the mode has nothing left", () => { + const pool = new MapPool({ ...MapPool.EMPTY.parsed, SZ: [1, 2] }); + + const [map] = starterMap({ + ...args, + modesIncluded: ["SZ"], + pool, + teams: [ + { id: 1, maps: new MapPool({ ...MapPool.EMPTY.parsed, SZ: [1] }) }, + { id: 2, maps: new MapPool({ ...MapPool.EMPTY.parsed, SZ: [2] }) }, + ], + }); + + expect(map.source).toBe("RANDOM"); + expect(pool.has(map)).toBe(true); }); }); diff --git a/app/modules/tournament-map-list-generator/starter-map.ts b/app/modules/tournament-map-list-generator/starter-map.ts index 6028fe445..aca1fd043 100644 --- a/app/modules/tournament-map-list-generator/starter-map.ts +++ b/app/modules/tournament-map-list-generator/starter-map.ts @@ -1,20 +1,28 @@ -// one mode: a common map, else one neither picked; tiebreaker: random; seeded +// a map both teams picked, else one from the pool neither picked; seeded -import { stageIds } from "~/modules/in-game-lists/stage-ids"; import { logger } from "~/utils/logger"; import { seededRandom } from "~/utils/random"; -import { modesShort } from "../in-game-lists/modes"; import type { ModeWithStage } from "../in-game-lists/types"; import type { TournamentMapListMap, TournamentMaplistInput } from "./types"; type StarterMapArgs = Pick< TournamentMaplistInput, - "modesIncluded" | "tiebreakerMaps" | "seed" | "teams" | "recentlyPlayedMaps" + | "modesIncluded" + | "pool" + | "seed" + | "teams" + | "recentlyPlayedMaps" + | "modeOrder" >; export function starterMap(args: StarterMapArgs): Array { const { seededShuffle } = seededRandom(args.seed); + const fixedMode = args.modeOrder?.[0] ?? null; + const isAllowedMode = (map: ModeWithStage) => + args.modesIncluded.includes(map.mode) && + (fixedMode === null || map.mode === fixedMode); + const isRecentlyPlayed = (map: ModeWithStage) => { return Boolean( args.recentlyPlayedMaps?.some( @@ -27,91 +35,54 @@ export function starterMap(args: StarterMapArgs): Array { args.teams, seededShuffle, isRecentlyPlayed, + isAllowedMode, ); if (commonMap) { return [{ ...commonMap, source: "BOTH" }]; } - if (!args.tiebreakerMaps.isEmpty()) { - const tiebreakers = seededShuffle(args.tiebreakerMaps.stageModePairs); - const nonRecentTiebreaker = tiebreakers.find((tb) => !isRecentlyPlayed(tb)); - const randomTiebreaker = nonRecentTiebreaker ?? tiebreakers[0]; + const poolMaps = seededShuffle( + args.pool.stageModePairs.filter(isAllowedMode), + ); + const pickedByATeam = (map: ModeWithStage) => + args.teams.some((team) => team.maps.has(map)); - return [ - { - mode: randomTiebreaker.mode, - stageId: randomTiebreaker.stageId, - source: "TIEBREAKER", - }, - ]; + const neitherPicked = poolMaps.filter((map) => !pickedByATeam(map)); + + const randomMap = + neitherPicked.find((map) => !isRecentlyPlayed(map)) ?? neitherPicked[0]; + if (randomMap) { + return [{ ...randomMap, source: "RANDOM" }]; } - // should only ever be one mode, but handles many just in case - const allAvailableMaps = seededShuffle( - args.modesIncluded - .sort((a, b) => modesShort.indexOf(a) - modesShort.indexOf(b)) - .flatMap((mode) => stageIds.map((stageId) => ({ mode, stageId }))), + logger.warn( + `starterMap: fallback choice, both teams together picked every pool map. Team IDs: ${args.teams.map((t) => t.id).join(", ")}`, ); - for (const map of allAvailableMaps) { - if ( - !args.teams.some((team) => - team.maps.stageModePairs.some( - (teamMap) => - teamMap.mode === map.mode && teamMap.stageId === map.stageId, - ), - ) && - !isRecentlyPlayed(map) - ) { - return [{ ...map, source: "DEFAULT" }]; - } - } + const fallbackMap = + poolMaps.find((map) => !isRecentlyPlayed(map)) ?? poolMaps[0]; - for (const map of allAvailableMaps) { - if ( - !args.teams.some((team) => - team.maps.stageModePairs.some( - (teamMap) => - teamMap.mode === map.mode && teamMap.stageId === map.stageId, - ), - ) - ) { - return [{ ...map, source: "DEFAULT" }]; - } - } - - logger.warn("starterMap: fallback choice"); - - return [{ ...allAvailableMaps[0], source: "DEFAULT" }]; + return fallbackMap ? [{ ...fallbackMap, source: "RANDOM" }] : []; } function resolveRandomCommonMap( teams: StarterMapArgs["teams"], shuffle: (o: T[]) => T[], isRecentlyPlayed: (map: ModeWithStage) => boolean, + isAllowedMode: (map: ModeWithStage) => boolean, ): ModeWithStage | null { const teamOnePicks = shuffle(teams[0].maps.stageModePairs); const teamTwoPicks = shuffle(teams[1].maps.stageModePairs); - for (const map of teamOnePicks) { - for (const map2 of teamTwoPicks) { - if ( - map.mode === map2.mode && - map.stageId === map2.stageId && - !isRecentlyPlayed(map) - ) { - return map; - } - } - } + const commonMaps = teamOnePicks.filter( + (map) => + isAllowedMode(map) && + teamTwoPicks.some( + (map2) => map.mode === map2.mode && map.stageId === map2.stageId, + ), + ); - for (const map of teamOnePicks) { - for (const map2 of teamTwoPicks) { - if (map.mode === map2.mode && map.stageId === map2.stageId) { - return map; - } - } - } - - return null; + return ( + commonMaps.find((map) => !isRecentlyPlayed(map)) ?? commonMaps[0] ?? null + ); } diff --git a/app/modules/tournament-map-list-generator/types.ts b/app/modules/tournament-map-list-generator/types.ts index 5c521f62e..71d47fadf 100644 --- a/app/modules/tournament-map-list-generator/types.ts +++ b/app/modules/tournament-map-list-generator/types.ts @@ -15,9 +15,11 @@ export interface TournamentMaplistInput { maps: MapPool; }, ]; - tiebreakerMaps: MapPool; + /** The tournament's effective map pool, where the random neutral maps are drawn from. */ + pool: MapPool; modesIncluded: ModeShort[]; - followModeOrder?: boolean; + /** Fixed mode of each slot. Without it the modes rotate in `modesIncluded` order from a seeded start. */ + modeOrder?: ModeShort[] | null; recentlyPlayedMaps?: ModeWithStage[]; } diff --git a/changelog/2026-09-16-team-picked-map-lists.md b/changelog/2026-09-16-team-picked-map-lists.md new file mode 100644 index 000000000..c91b97593 --- /dev/null +++ b/changelog/2026-09-16-team-picked-map-lists.md @@ -0,0 +1,10 @@ +--- +navItem: [calendar, medal] +type: feature +--- +Team picked map lists overhaul + +- Choose which modes teams pick maps for, Turf War included, and how many maps for each mode +- Restrict team picks to SendouQ legal maps, all maps or a custom map pool +- Tiebreaker map pool is gone: the deciding map is one both teams picked, or a random map from the pool neither team picked +- Fix the mode order of team picked rounds when starting a bracket, e.g. every round starts with Splat Zones diff --git a/docs/dev/database-relations.md b/docs/dev/database-relations.md index 907d86791..9bc730a09 100644 --- a/docs/dev/database-relations.md +++ b/docs/dev/database-relations.md @@ -95,7 +95,6 @@ erDiagram ```mermaid erDiagram MapPoolMap }o--|| CalendarEvent : calendar_event - MapPoolMap }o--|| CalendarEvent : tie_breaker_calendar_event MapPoolMap }o--|| TournamentTeam : tournament_team ``` @@ -103,9 +102,8 @@ erDiagram Can be one of the following: 1) Regular calendar events map pool -2) Tournament's tiebreaker maps (teams' pick mode, AUTO_ALL) -3) Tournament's map pool (TO's map picking mode) -4) Tournament teams map picks (teams' pick mode, AUTO_ALL, AUTO_SZ etc.) +2) Tournament's map pool (organizer picked, or the custom pool of a team picked tournament) +3) Tournament teams map picks (team picked, the modes and counts live in `Tournament.settings.teamPick`) ## Plus Server Suggestions diff --git a/docs/tournament-creation.md b/docs/tournament-creation.md index c65ebc476..18e962623 100644 --- a/docs/tournament-creation.md +++ b/docs/tournament-creation.md @@ -134,15 +134,15 @@ Unlike test mode, draft tournaments are fully functional once opened: they appea ## Tournament maps -With sendou.ink tournaments all maps are decided ahead of time. The map picking style can't be changed after the tournament has been created. +With sendou.ink tournaments all maps are decided ahead of time. The map settings can be edited until the tournament starts, but changing them resets the maps every team has already picked. -### Prepicked by teams +### Team picked -Map pool is always the same as current SendouQ seasonal map pool in terms of bans. +The tournament organizer chooses which modes are played (any combination, Turf War included) and how many maps each team picks in every mode. By default a one mode tournament has teams pick 6 maps, two modes 4 maps each, three modes 3 maps each and four or more modes 2 maps each. -For SZ/TC/RM/CB only no maps are picked by the tournament organizer. Teams pick 6 maps of that mode. +The maps teams can pick from are either the SendouQ legal maps, all maps or a custom map pool built by the organizer. A custom pool needs at least one more stage than teams pick in every mode. -For all modes the tournament organizer picks one tiebreaker map per mode. Teams pick 2 maps per mode. +When a match is played the last map of the list is a map both teams picked when one exists, otherwise a random map from the pool that neither team picked. When starting a bracket the organizer can fix the mode order of the rounds with the same mode patterns as organizer picked tournaments use. ![alt text](./img/tournament-team-map-pick.png) *Team picking maps as part of their registration process* @@ -153,7 +153,7 @@ Then when the tournament in in progress an algorithm decides the map list for ea [More info on how it works](https://gist.github.com/sendou-ink/285c697ad98171243bf5c08a4c7e1f30). -### Picked by TO +### Organizer picked Note that here you select just the map pool. The actual map lists are picked when the bracket starts (or prepared) in advance: diff --git a/e2e/calendar.spec.ts b/e2e/calendar.spec.ts index 543d5478f..d0ddd67e3 100644 --- a/e2e/calendar.spec.ts +++ b/e2e/calendar.spec.ts @@ -228,8 +228,8 @@ test.describe("Calendar", () => { // flip a tournament setting away from its default await newTournament.form.check("requireInGameNames"); - // "Picked by TO" allows an arbitrary map pool, unlike the validated tiebreaker modes - await newTournament.form.select("toToolsMode", "TO"); + // "Organizer picked" allows an arbitrary map pool, unlike the validated team pick pools + await newTournament.form.checkItems("mapPickingStyle", ["TO"]); await newTournament.pickMapPool(mapPool); await newTournament.addFollowUpBracket({ @@ -260,6 +260,68 @@ test.describe("Calendar", () => { } }); + test("creates a team picked tournament with a custom map pool", async ({ + page, + factories, + }) => { + const organizer = await factories.UserFactory.create(null, { + roles: ["TOURNAMENT_ORGANIZER"], + }); + + await impersonate(page, organizer.id); + + const newTournament = new CalendarNewEventPage(page); + await newTournament.gotoNewTournament(); + + await newTournament.form.fill("name", "Team Pick Tournament"); + await newTournament.setFirstDate(new Date(2027, 0, 15, 17, 0)); + + await newTournament.form.checkItems("mapPickingStyle", ["AUTO"]); + await newTournament.setTeamPickModes(["Splat Zones", "Tower Control"]); + await newTournament.teamPickCountInput("Splat Zones").fill("2"); + await newTournament.teamPickCountInput("Tower Control").fill("2"); + await newTournament.form.checkItems("teamPickPool", ["CUSTOM"]); + + const stages = ["Scorch Gorge", "Eeltail Alley", "Hagglefish Market"]; + const customPool = stages.flatMap((stage) => [ + { stage, mode: "Splat Zones" }, + { stage, mode: "Tower Control" }, + ]); + + // every mode needs one stage more than the teams pick in it + await newTournament.pickMapPool( + customPool.filter((map) => map.stage !== "Hagglefish Market"), + ); + await expect( + newTournament.teamPickPoolStatus("SZ needs at least 3 stages (has 2)"), + ).toBeVisible(); + + await newTournament.pickMapPool( + customPool.filter((map) => map.stage === "Hagglefish Market"), + ); + await expect( + newTournament.teamPickPoolStatus( + "The map pool has enough stages in every mode", + ), + ).toBeVisible(); + + await newTournament.form.submit(); + + await expect(page).toHaveURL(/\/to\/\d+/); + const tournamentId = Number(page.url().match(/\/to\/(\d+)/)?.[1]); + + // the picked modes, counts and custom pool round-trip onto the rules page + const rules = new TournamentRulesPage(page); + await rules.goto(tournamentId); + await expect(rules.rule("each team picking 2× SZ, 2× TC")).toBeVisible(); + await expect( + rules.rule("Maps are limited to the map pool above"), + ).toBeVisible(); + for (const stage of stages) { + await expect(rules.stageName(stage)).toBeVisible(); + } + }); + test("reports winners of a past event", async ({ page, factories }) => { const event = await factories.CalendarEventFactory.create({ authorId: ADMIN_ID, diff --git a/e2e/helpers/counterpick-map-pool.ts b/e2e/helpers/counterpick-map-pool.ts index aeffc2b58..b340ea86d 100644 --- a/e2e/helpers/counterpick-map-pool.ts +++ b/e2e/helpers/counterpick-map-pool.ts @@ -1,10 +1,10 @@ import type { Page } from "@playwright/test"; import { BANNED_MAPS } from "~/features/match-profile/banned-maps"; -import { TOURNAMENT } from "~/features/tournament/tournament-constants"; +import * as TeamPick from "~/features/tournament/core/TeamPick"; 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. */ +/** Stage the counterpick picking starts from. */ const FIRST_COUNTERPICK_STAGE_ID = 5; /** A stage of a `ModeMapPoolPicker`, whether picked or not. */ @@ -21,13 +21,14 @@ export function pickedCounterpickMap( return page.getByTestId(`map-pool-${mode}-${stageId}-picked`); } -/** Picks the required amount of counterpick maps for every ranked mode, skipping banned ones, and returns them. */ +/** Picks the default amount of counterpick maps for every ranked mode, skipping banned ones, and returns them. */ 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++) { + for (const { mode, count } of TeamPick.defaultSettings([...rankedModesShort]) + .modes) { + for (let i = 0; i < count; i++) { while (BANNED_MAPS[mode].includes(stageId as StageId)) { stageId++; } diff --git a/e2e/pages/calendar/calendar-new-event-page.ts b/e2e/pages/calendar/calendar-new-event-page.ts index 23b44d0c2..4d201a6fb 100644 --- a/e2e/pages/calendar/calendar-new-event-page.ts +++ b/e2e/pages/calendar/calendar-new-event-page.ts @@ -4,6 +4,14 @@ import { CALENDAR_NEW_PAGE, TOURNAMENT_NEW_PAGE } from "~/utils/urls"; import { datetimeLocalValue, navigate, submit } from "../../helpers/playwright"; import { createFormHelpers } from "../../helpers/playwright-form"; +const ALL_MODE_NAMES = [ + "Turf War", + "Splat Zones", + "Tower Control", + "Rainmaker", + "Clam Blitz", +]; + /** `/calendar/new`, also used for adding tournaments and editing existing events. */ export class CalendarNewEventPage { private readonly page: Page; @@ -51,8 +59,35 @@ export class CalendarNewEventPage { .fill(datetimeLocalValue(date)); } - // the TO map pool grid exposes each map as a mode button inside a group labelled - // by its stage name + // a mode's checkbox and its "Maps per mode" input share the mode's name as + // their label, so they are told apart by role + teamPickModeCheckbox(modeName: string) { + return this.page.getByRole("checkbox", { name: modeName }); + } + + teamPickCountInput(modeName: string) { + return this.page.getByRole("spinbutton", { name: modeName }); + } + + /** Text of the alert below the custom team pick pool, telling whether it has enough stages. */ + teamPickPoolStatus(text: string) { + return this.page.getByText(text); + } + + /** Checks exactly the given modes for teams to pick maps in. */ + async setTeamPickModes(modeNames: string[]) { + for (const modeName of ALL_MODE_NAMES) { + const checkbox = this.teamPickModeCheckbox(modeName); + const shouldBeChecked = modeNames.includes(modeName); + + if ((await checkbox.isChecked()) !== shouldBeChecked) { + await checkbox.click(); + } + } + } + + // the map pool grid exposes each map as a mode button inside a group labelled + // by its stage name, both for the TO pool and the custom team pick pool async pickMapPool(maps: Array<{ stage: string; mode: string }>) { for (const { stage, mode } of maps) { await this.page diff --git a/e2e/pages/tournament/tournament-rules-page.ts b/e2e/pages/tournament/tournament-rules-page.ts index fdf0508ff..ec8aa4b4c 100644 --- a/e2e/pages/tournament/tournament-rules-page.ts +++ b/e2e/pages/tournament/tournament-rules-page.ts @@ -24,4 +24,9 @@ export class TournamentRulesPage { modeImage(mode: string) { return this.page.getByAltText(mode); } + + /** A line of the team pick rules, matched by (part of) its text. */ + rule(text: string) { + return this.page.getByText(text); + } } diff --git a/e2e/tournament-ab-divisions.spec.ts b/e2e/tournament-ab-divisions.spec.ts index 7fb1052db..2805d865e 100644 --- a/e2e/tournament-ab-divisions.spec.ts +++ b/e2e/tournament-ab-divisions.spec.ts @@ -23,7 +23,7 @@ test.describe("Tournament A/B divisions", () => { { authorId: NZAP_TEST_ID, startTimes: [dateToDatabaseTimestamp(subMinutes(new Date(), 30))], - mapPickingStyle: "AUTO_ALL", + mapPickingStyle: "AUTO", bracketProgression: [ { type: "round_robin", diff --git a/e2e/tournament-admin.spec.ts b/e2e/tournament-admin.spec.ts index 31be48c3a..5f97fa7f8 100644 --- a/e2e/tournament-admin.spec.ts +++ b/e2e/tournament-admin.spec.ts @@ -171,7 +171,7 @@ test.describe("Tournament admin team management", () => { }) => { // teams pre-pick their maps, so the registration form has a map pool to edit const tournament = await createTournament(factories, { - mapPickingStyle: "AUTO_ALL", + mapPickingStyle: "AUTO", }); const roster = await factories.UserFactory.createMany(ROSTER_SIZE); const team = await factories.TournamentTeamFactory.create({ diff --git a/e2e/tournament-bracket-multi-stage.spec.ts b/e2e/tournament-bracket-multi-stage.spec.ts index c58797657..489c28384 100644 --- a/e2e/tournament-bracket-multi-stage.spec.ts +++ b/e2e/tournament-bracket-multi-stage.spec.ts @@ -321,7 +321,7 @@ test.describe("Tournament bracket multi stage", () => { // start time in the past so the brackets can be started right away await newTournament.setFirstDate(subMinutes(new Date(), 30)); - await newTournament.form.select("toToolsMode", "TO"); + await newTournament.form.checkItems("mapPickingStyle", ["TO"]); await newTournament.selectMapPoolTemplate("preset:SZ"); // groups of 4: top 2 advance to the finals directly, 3rd placers get diff --git a/e2e/tournament-tiers.spec.ts b/e2e/tournament-tiers.spec.ts index b1cc4ed5a..63fdaa297 100644 --- a/e2e/tournament-tiers.spec.ts +++ b/e2e/tournament-tiers.spec.ts @@ -54,7 +54,8 @@ test.describe("Tournament tiers", () => { organizationId: org.id, startTimes: [dateToDatabaseTimestamp(subMinutes(new Date(), 30))], tags: null, - mapPickingStyle: "AUTO_SZ", + mapPickingStyle: "AUTO", + teamPick: { modes: [{ mode: "SZ", count: 6 }], pool: "SENDOUQ" }, }); for (const roster of rosters) { await factories.TournamentTeamFactory.create( diff --git a/e2e/tournament.spec.ts b/e2e/tournament.spec.ts index 2041174b2..19e279094 100644 --- a/e2e/tournament.spec.ts +++ b/e2e/tournament.spec.ts @@ -44,7 +44,7 @@ test.describe("Tournament", () => { authorId: ADMIN_ID, startTimes: [dateToDatabaseTimestamp(addHours(new Date(), 2))], // teams pick their own counterpick maps - mapPickingStyle: "AUTO_ALL", + mapPickingStyle: "AUTO", }); await impersonate(page, captain.id); diff --git a/locales/da/calendar.json b/locales/da/calendar.json index b9f7e65e0..e22aa1618 100644 --- a/locales/da/calendar.json +++ b/locales/da/calendar.json @@ -18,6 +18,8 @@ "forms.trophy": "", "forms.trophy.placeholder": "", "forms.mapPool": "Banepulje", + "forms.teamPick.poolShortfall": "", + "forms.teamPick.poolOk": "", "forms.reportResultsHeader": "Viser resultater for {{eventName}}", "forms.reportResultsInfo": "Du vælger hvor mange resultater der skal vises. Det kan være det vindende hold eller top 3.", "forms.team.placing": "Placering", diff --git a/locales/da/common.json b/locales/da/common.json index 4249db21c..861f05cd6 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -206,7 +206,6 @@ "maps.halfSz": "50% DD", "maps.mapPool": "Banepulje", "maps.tournamentMaplist": "Lav turneringsbanepuljer på (maps.iplabs.ink)", - "maps.tieBreakerMapPool": "Tiebreak-banepulje", "maps.template": "Skabelon", "maps.template.none": "Ingen", "maps.template.presets": "Forudindstillet", @@ -214,14 +213,10 @@ "maps.template.preset.ANARCHY": "Anarchy-spiltilstande", "maps.template.preset.ALL": "Alle spiltilstande", "maps.template.preset.onlyMode": "Kun {{modeName}}", - "maps.validation.PICKING": "Vælg en bane per spiltilstand", "maps.validation.STAGE_REPEAT_IN_SAME_MODE": "Man kan ikke vælge den samme bane to gange i den samme spiltilstand", - "maps.validation.NOT_ONE_MAP_PER_MODE": "Vælg kun en bane per spiltilstand", - "maps.validation.MAP_REPEATED": "Man kan ikke vælge den samme bane 2 gange", - "maps.validation.MODE_REPEATED": "Man kan ikke vælge den samme spiltilstand 2 gange", "maps.validation.TOO_MUCH_STAGE_REPEAT": "Man kan ikke vælge den samme bane mere end {{maxStageRepeat}} gang(e)", - "maps.validation.INCLUDES_BANNED": "", - "maps.validation.INCLUDES_TIEBREAKER": "", + "maps.validation.NOT_IN_POOL": "", + "maps.banned": "", "maps.validation.VALID": "Banepuljen er gyldig", "results": "Resultater", "forms.name": "Navn", diff --git a/locales/da/forms.json b/locales/da/forms.json index deb492524..4217ce762 100644 --- a/locales/da/forms.json +++ b/locales/da/forms.json @@ -146,6 +146,9 @@ "errors.staffCannotBeAuthor": "", "errors.divBothOrNeither": "", "errors.invalidMapPool": "", + "errors.teamPick.noModes": "", + "errors.teamPick.poolTooSmall": "", + "errors.teamPick.countOutOfRange": "", "errors.scrimTournamentRequired": "", "errors.tournamentMustBeSelected": "", "errors.visibilityMustBeDifferent": "", @@ -349,6 +352,16 @@ "labels.draft": "", "labels.requireSendouQ": "", "labels.mapPickingStyle": "", + "labels.teamPickModes": "", + "labels.teamPickCounts": "", + "bottomTexts.teamPickCounts": "", + "labels.teamPickPool": "", + "options.mapPickingStyle.TO": "", + "options.mapPickingStyle.AUTO": "", + "options.teamPickPool.SENDOUQ": "", + "options.teamPickPool.ALL": "", + "options.teamPickPool.CUSTOM": "", + "bottomTexts.teamPickReset": "", "bottomTexts.regClosesAt": "", "bottomTexts.maxTeamSize": "", "bottomTexts.avatarValidation": "", @@ -361,12 +374,6 @@ "bottomTexts.test": "", "bottomTexts.draftInfo": "", "bottomTexts.requireSendouQ": "", - "options.toToolsMode.ALL": "", - "options.toToolsMode.SZ": "", - "options.toToolsMode.TC": "", - "options.toToolsMode.RM": "", - "options.toToolsMode.CB": "", - "options.toToolsMode.TO": "", "options.regClosesAt.0": "", "options.regClosesAt.5min": "", "options.regClosesAt.10min": "", @@ -382,7 +389,6 @@ "options.regClosesAt.24h": "", "options.regClosesAt.48h": "", "options.regClosesAt.72h": "", - "errors.allModePool": "", "errors.bracketUrlRequired": "", "errors.bracketProgressionRequired": "", "labels.brackets": "", diff --git a/locales/da/tournament.json b/locales/da/tournament.json index 2f0d77de6..9d74271b0 100644 --- a/locales/da/tournament.json +++ b/locales/da/tournament.json @@ -68,7 +68,16 @@ "pre.googleForm.header": "", "pre.googleForm.link": "", "pre.googleForm.footer": "", - "rules.tiebreakerMapPool": "", + "rules.teamPick.picks": "", + "rules.teamPick.pool.SENDOUQ": "", + "rules.teamPick.pool.ALL": "", + "rules.teamPick.pool.CUSTOM": "", + "rules.teamPick.neutral": "", + "mapList.rerollModeOrder": "", + "mapList.rerollAllMaps": "", + "mapList.teamsPick": "", + "mapList.customFlow": "", + "rules.teamPick.stageRepeat": "", "team.label": "Hold", "team.teamPage": "", "team.vs": "", @@ -80,7 +89,7 @@ "pickInfo.team.counterpick": "", "pickInfo.pickedBy": "", "pickInfo.counterpickedBy": "", - "pickInfo.tiebreaker": "Tiebreak", + "pickInfo.random": "", "pickInfo.both": "begge hold valgte", "pickInfo.default": "", "pickInfo.counterpick": "Modvalg", diff --git a/locales/de/calendar.json b/locales/de/calendar.json index 0bb9b8789..43bf4fdd4 100644 --- a/locales/de/calendar.json +++ b/locales/de/calendar.json @@ -18,6 +18,8 @@ "forms.trophy": "", "forms.trophy.placeholder": "", "forms.mapPool": "Arenen-Pool", + "forms.teamPick.poolShortfall": "", + "forms.teamPick.poolOk": "", "forms.reportResultsHeader": "Berichten der Ergebnisse von {{eventName}}", "forms.reportResultsInfo": "Die Anzahl der eintragbaren Ergebnisse ist frei wählbar. Es kann nur das erste Team sein, die Top 3 oder mehr.", "forms.team.placing": "Platz", diff --git a/locales/de/common.json b/locales/de/common.json index 4ee4d302f..762a72a01 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -206,7 +206,6 @@ "maps.halfSz": "50% Herrschaft", "maps.mapPool": "Arenen-Pool", "maps.tournamentMaplist": "Arenen-Liste für Turnier erstellen (maps.iplabs.ink)", - "maps.tieBreakerMapPool": "Arenen-Pool für Tie-Breaker", "maps.template": "Vorlage", "maps.template.none": "Keine", "maps.template.presets": "Voreinstellungen", @@ -214,14 +213,10 @@ "maps.template.preset.ANARCHY": "Anarchie-Modi", "maps.template.preset.ALL": "Alle Modi", "maps.template.preset.onlyMode": "Nur {{modeName}}", - "maps.validation.PICKING": "Wähle eine Arena pro Modus", "maps.validation.STAGE_REPEAT_IN_SAME_MODE": "", - "maps.validation.NOT_ONE_MAP_PER_MODE": "Wähle nur eine Arena pro Modus", - "maps.validation.MAP_REPEATED": "Dieselbe Arena kann nicht mehrmals ausgewählt werden", - "maps.validation.MODE_REPEATED": "Derselbe Modus kann nicht mehrmals ausgewählt werden", "maps.validation.TOO_MUCH_STAGE_REPEAT": "Arena kann nicht mehr als {{maxStageRepeat}} Mal ausgewählt werden", - "maps.validation.INCLUDES_BANNED": "", - "maps.validation.INCLUDES_TIEBREAKER": "", + "maps.validation.NOT_IN_POOL": "", + "maps.banned": "", "maps.validation.VALID": "Arenen-Pool ok!", "results": "Ergebnisse", "forms.name": "Name", diff --git a/locales/de/forms.json b/locales/de/forms.json index 1d7f36064..c6bc7f0da 100644 --- a/locales/de/forms.json +++ b/locales/de/forms.json @@ -146,6 +146,9 @@ "errors.staffCannotBeAuthor": "", "errors.divBothOrNeither": "", "errors.invalidMapPool": "", + "errors.teamPick.noModes": "", + "errors.teamPick.poolTooSmall": "", + "errors.teamPick.countOutOfRange": "", "errors.scrimTournamentRequired": "", "errors.tournamentMustBeSelected": "", "errors.visibilityMustBeDifferent": "", @@ -349,6 +352,16 @@ "labels.draft": "", "labels.requireSendouQ": "", "labels.mapPickingStyle": "", + "labels.teamPickModes": "", + "labels.teamPickCounts": "", + "bottomTexts.teamPickCounts": "", + "labels.teamPickPool": "", + "options.mapPickingStyle.TO": "", + "options.mapPickingStyle.AUTO": "", + "options.teamPickPool.SENDOUQ": "", + "options.teamPickPool.ALL": "", + "options.teamPickPool.CUSTOM": "", + "bottomTexts.teamPickReset": "", "bottomTexts.regClosesAt": "", "bottomTexts.maxTeamSize": "", "bottomTexts.avatarValidation": "", @@ -361,12 +374,6 @@ "bottomTexts.test": "", "bottomTexts.draftInfo": "", "bottomTexts.requireSendouQ": "", - "options.toToolsMode.ALL": "", - "options.toToolsMode.SZ": "", - "options.toToolsMode.TC": "", - "options.toToolsMode.RM": "", - "options.toToolsMode.CB": "", - "options.toToolsMode.TO": "", "options.regClosesAt.0": "", "options.regClosesAt.5min": "", "options.regClosesAt.10min": "", @@ -382,7 +389,6 @@ "options.regClosesAt.24h": "", "options.regClosesAt.48h": "", "options.regClosesAt.72h": "", - "errors.allModePool": "", "errors.bracketUrlRequired": "", "errors.bracketProgressionRequired": "", "labels.brackets": "", diff --git a/locales/de/tournament.json b/locales/de/tournament.json index fa56e48e8..9bcab21f2 100644 --- a/locales/de/tournament.json +++ b/locales/de/tournament.json @@ -68,7 +68,16 @@ "pre.googleForm.header": "", "pre.googleForm.link": "", "pre.googleForm.footer": "", - "rules.tiebreakerMapPool": "", + "rules.teamPick.picks": "", + "rules.teamPick.pool.SENDOUQ": "", + "rules.teamPick.pool.ALL": "", + "rules.teamPick.pool.CUSTOM": "", + "rules.teamPick.neutral": "", + "mapList.rerollModeOrder": "", + "mapList.rerollAllMaps": "", + "mapList.teamsPick": "", + "mapList.customFlow": "", + "rules.teamPick.stageRepeat": "", "team.label": "Team", "team.teamPage": "", "team.vs": "", @@ -80,7 +89,7 @@ "pickInfo.team.counterpick": "", "pickInfo.pickedBy": "", "pickInfo.counterpickedBy": "", - "pickInfo.tiebreaker": "Tiebreaker", + "pickInfo.random": "", "pickInfo.both": "Von beiden ausgewählt", "pickInfo.default": "", "pickInfo.counterpick": "Counterpick", diff --git a/locales/en/calendar.json b/locales/en/calendar.json index b228924b7..84fcc2c52 100644 --- a/locales/en/calendar.json +++ b/locales/en/calendar.json @@ -18,6 +18,8 @@ "forms.trophy": "Trophy prize", "forms.trophy.placeholder": "Choose a trophy prize", "forms.mapPool": "Map pool", + "forms.teamPick.poolShortfall": "{{mode}} needs at least {{required}} stages (has {{has}})", + "forms.teamPick.poolOk": "The map pool has enough stages in every mode", "forms.reportResultsHeader": "Reporting results for {{eventName}}", "forms.reportResultsInfo": "You choose how many results to report. It can be just the winning team, top 3 or whatever you decide.", "forms.team.placing": "Placing", diff --git a/locales/en/common.json b/locales/en/common.json index 0ff88a0be..b86ec5417 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -206,7 +206,6 @@ "maps.halfSz": "50% SZ", "maps.mapPool": "Map pool", "maps.tournamentMaplist": "Create tournament map list (maps.iplabs.ink)", - "maps.tieBreakerMapPool": "Tiebreaker map pool", "maps.template": "Template", "maps.template.none": "None", "maps.template.presets": "Presets", @@ -214,14 +213,10 @@ "maps.template.preset.ANARCHY": "Anarchy Modes", "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", "maps.validation.TOO_MUCH_STAGE_REPEAT": "Can't pick stage more than {{maxStageRepeat}} times", - "maps.validation.INCLUDES_BANNED": "Can't pick banned stages", - "maps.validation.INCLUDES_TIEBREAKER": "Can't pick the tiebreaker stage", + "maps.validation.NOT_IN_POOL": "Can't pick stages outside of the map pool", + "maps.banned": "Banned", "maps.validation.VALID": "Map pool ok!", "results": "Results", "forms.name": "Name", diff --git a/locales/en/forms.json b/locales/en/forms.json index 9bf38ed4e..cd3cc2fdd 100644 --- a/locales/en/forms.json +++ b/locales/en/forms.json @@ -146,6 +146,9 @@ "errors.staffCannotBeAuthor": "The tournament author is already an organizer", "errors.divBothOrNeither": "Both min and max div must be set or neither", "errors.invalidMapPool": "Invalid map pool", + "errors.teamPick.noModes": "Pick at least one mode", + "errors.teamPick.poolTooSmall": "For every mode, the custom map pool needs at least one more stage than teams pick in it", + "errors.teamPick.countOutOfRange": "Maps per mode must be at least 1 and less than the number of stages in the pool", "errors.scrimTournamentRequired": "Please select a tournament", "errors.tournamentMustBeSelected": "Tournament must be selected when maps is tournament", "errors.visibilityMustBeDifferent": "Not found visibility must be different from base visibility", @@ -349,6 +352,16 @@ "labels.draft": "Draft", "labels.requireSendouQ": "Require SendouQ participation", "labels.mapPickingStyle": "Map picking style", + "labels.teamPickModes": "Modes", + "labels.teamPickCounts": "Maps per mode", + "bottomTexts.teamPickCounts": "How many maps each team picks per mode", + "labels.teamPickPool": "Map pool", + "options.mapPickingStyle.TO": "Organizer picked", + "options.mapPickingStyle.AUTO": "Team picked", + "options.teamPickPool.SENDOUQ": "SendouQ legal maps", + "options.teamPickPool.ALL": "All maps", + "options.teamPickPool.CUSTOM": "Custom", + "bottomTexts.teamPickReset": "Changing the map settings resets the maps every team has already picked", "bottomTexts.regClosesAt": "All times relative to the reported tournament start time e.g. \"30 minutes\" means \"30 minutes before event start time\". After registration closes only TO's can make changes to team rosters.", "bottomTexts.maxTeamSize": "Maximum number of players that can be registered per team. Doesn't apply to tournament organizers. Default is 6.", "bottomTexts.avatarValidation": "Note that for non-patrons there is a validation process before avatar is shown.", @@ -361,12 +374,6 @@ "bottomTexts.test": "Test tournaments don't appear on the calendar, don't send notifications to players, and won't show up in players' profiles or results", "bottomTexts.draftInfo": "Draft tournaments are hidden and only visible to organizers. The tournament must be opened (by disabling this toggle) before any bracket can be started.", "bottomTexts.requireSendouQ": "Players must have played enough SendouQ matches this season to register", - "options.toToolsMode.ALL": "Prepicked by teams - All modes", - "options.toToolsMode.SZ": "Prepicked by teams - SZ only", - "options.toToolsMode.TC": "Prepicked by teams - TC only", - "options.toToolsMode.RM": "Prepicked by teams - RM only", - "options.toToolsMode.CB": "Prepicked by teams - CB only", - "options.toToolsMode.TO": "Picked by TO", "options.regClosesAt.0": "At the start time", "options.regClosesAt.5min": "5 minutes", "options.regClosesAt.10min": "10 minutes", @@ -382,7 +389,6 @@ "options.regClosesAt.24h": "24 hours", "options.regClosesAt.48h": "48 hours", "options.regClosesAt.72h": "72 hours", - "errors.allModePool": "Map pool must contain a map for each ranked mode if using \"Prepicked by teams - All modes\"", "errors.bracketUrlRequired": "Bracket URL is required", "errors.bracketProgressionRequired": "Bracket progression must be set for tournaments", "labels.brackets": "Brackets", diff --git a/locales/en/tournament.json b/locales/en/tournament.json index 310baa8d4..0f19721c9 100644 --- a/locales/en/tournament.json +++ b/locales/en/tournament.json @@ -68,7 +68,16 @@ "pre.googleForm.header": "Additional Requirement: Google Form", "pre.googleForm.link": "Answer survey hosted on Google Forms", "pre.googleForm.footer": "Answer to additional question about your team's preferred match time and info to help with seeding", - "rules.tiebreakerMapPool": "Tiebreaker map pool: {{maps}}", + "rules.teamPick.picks": "Teams pick their own maps before the tournament, each team picking {{picks}}", + "rules.teamPick.pool.SENDOUQ": "Maps are limited to the SendouQ legal maps", + "rules.teamPick.pool.ALL": "Every map is allowed", + "rules.teamPick.pool.CUSTOM": "Maps are limited to the map pool above", + "rules.teamPick.neutral": "If both teams picked the same map, it is used as the deciding map, otherwise a random map from the pool that neither team picked", + "mapList.rerollModeOrder": "Reroll mode order", + "mapList.rerollAllMaps": "Reroll all maps", + "mapList.teamsPick": "Team's pick", + "mapList.customFlow": "Custom flow", + "rules.teamPick.stageRepeat": "A stage can appear in at most {{cap}} modes of a team's picks", "team.label": "Team", "team.teamPage": "Team page", "team.vs": "vs.", @@ -80,7 +89,7 @@ "pickInfo.team.counterpick": "{{team}} counterpicked", "pickInfo.pickedBy": "Picked by {{teamName}}", "pickInfo.counterpickedBy": "Counterpicked by {{teamName}}", - "pickInfo.tiebreaker": "Tiebreaker", + "pickInfo.random": "Random", "pickInfo.both": "Both picked", "pickInfo.default": "Community's choice", "pickInfo.counterpick": "Counterpick", diff --git a/locales/es-ES/calendar.json b/locales/es-ES/calendar.json index bd1b5cc75..912d55517 100644 --- a/locales/es-ES/calendar.json +++ b/locales/es-ES/calendar.json @@ -20,6 +20,8 @@ "forms.trophy": "Premio de trofeo", "forms.trophy.placeholder": "Elige un trofeo como premio", "forms.mapPool": "Grupo de mapas", + "forms.teamPick.poolShortfall": "", + "forms.teamPick.poolOk": "", "forms.reportResultsHeader": "Anunciando resultados para {{eventName}}", "forms.reportResultsInfo": "Tú decides cuántos resultados reportar. Puede ser solo el equipo ganador, los 3 primeros o lo que quieras.", "forms.team.placing": "Lugar", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 1c24e3fa6..6d386fdad 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -206,7 +206,6 @@ "maps.halfSz": "50% Pintazonas", "maps.mapPool": "Rotación de mapas", "maps.tournamentMaplist": "Crear lista de mapas para torneo (maps.iplabs.ink)", - "maps.tieBreakerMapPool": "Mapas para desempate", "maps.template": "Plantilla", "maps.template.none": "Ninguna", "maps.template.presets": "Preajustes", @@ -214,14 +213,10 @@ "maps.template.preset.ANARCHY": "Modos caóticos", "maps.template.preset.ALL": "Todos los modos", "maps.template.preset.onlyMode": "Solo {{modeName}}", - "maps.validation.PICKING": "Elige un escenario por modo", "maps.validation.STAGE_REPEAT_IN_SAME_MODE": "No se permite un escenario dos veces en el mismo modo", - "maps.validation.NOT_ONE_MAP_PER_MODE": "Elige solo un escenario por modo", - "maps.validation.MAP_REPEATED": "No se permite el mismo escenario más de una vez", - "maps.validation.MODE_REPEATED": "No se permite el mismo modo más de una vez", "maps.validation.TOO_MUCH_STAGE_REPEAT": "No se permite un escenario más de {{maxStageRepeat}} veces", - "maps.validation.INCLUDES_BANNED": "No se permiten escenarios prohibidos", - "maps.validation.INCLUDES_TIEBREAKER": "No se permite el escenario de desempate", + "maps.validation.NOT_IN_POOL": "", + "maps.banned": "", "maps.validation.VALID": "¡Rotación de mapas OK!", "results": "Resultados", "forms.name": "Nombre", diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json index 614a86e66..7cc18f5d8 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -146,6 +146,9 @@ "errors.staffCannotBeAuthor": "El autor del torneo ya es organizador", "errors.divBothOrNeither": "Deben establecerse tanto la div. mínima como la máxima, o ninguna", "errors.invalidMapPool": "Rotación de escenarios no válida", + "errors.teamPick.noModes": "", + "errors.teamPick.poolTooSmall": "", + "errors.teamPick.countOutOfRange": "", "errors.scrimTournamentRequired": "Por favor, selecciona un torneo", "errors.tournamentMustBeSelected": "Debe seleccionarse un torneo cuando los mapas son de torneo", "errors.visibilityMustBeDifferent": "La visibilidad de 'no encontrado' debe ser diferente a la visibilidad base", @@ -349,6 +352,16 @@ "labels.draft": "Borrador", "labels.requireSendouQ": "Requerir participación en SendouQ", "labels.mapPickingStyle": "Estilo de selección de escenario", + "labels.teamPickModes": "", + "labels.teamPickCounts": "", + "bottomTexts.teamPickCounts": "", + "labels.teamPickPool": "", + "options.mapPickingStyle.TO": "", + "options.mapPickingStyle.AUTO": "", + "options.teamPickPool.SENDOUQ": "", + "options.teamPickPool.ALL": "", + "options.teamPickPool.CUSTOM": "", + "bottomTexts.teamPickReset": "", "bottomTexts.regClosesAt": "Todos los horarios son relativos a la hora de inicio del torneo. Ej. \"30 minutos\" significa \"30 minutos antes del inicio del evento\". Una vez cerrada la inscripción, solo los TOs pueden cambiar los rosters.", "bottomTexts.maxTeamSize": "Número máximo de jugadores que pueden registrarse por equipo. No aplica a los organizadores del torneo. El valor por defecto es 6.", "bottomTexts.avatarValidation": "Ten en cuenta que para los no mecenas hay un proceso de validación antes de que se muestre el avatar.", @@ -361,12 +374,6 @@ "bottomTexts.test": "Los torneos de prueba no aparecen en el calendario, no envían notificaciones a los jugadores y no se mostrarán en los perfiles ni resultados de los jugadores", "bottomTexts.draftInfo": "Los torneos borrador están ocultos y solo son visibles para los organizadores. El torneo debe abrirse (desactivando esta opción) antes de poder empezar cualquier bracket.", "bottomTexts.requireSendouQ": "Los jugadores deben haber jugado suficientes combates de SendouQ esta temporada para inscribirse", - "options.toToolsMode.ALL": "Preseleccionado por los equipos - Todos los modos", - "options.toToolsMode.SZ": "Preseleccionado por los equipos - Solo Pintazonas", - "options.toToolsMode.TC": "Preseleccionado por los equipos - Solo Torre", - "options.toToolsMode.RM": "Preseleccionado por los equipos - Solo Pez dorado", - "options.toToolsMode.CB": "Preseleccionado por los equipos - Solo Asalto Almeja", - "options.toToolsMode.TO": "Seleccionado por el TO", "options.regClosesAt.0": "A la hora de inicio", "options.regClosesAt.5min": "5 minutos", "options.regClosesAt.10min": "10 minutos", @@ -382,7 +389,6 @@ "options.regClosesAt.24h": "24 horas", "options.regClosesAt.48h": "48 horas", "options.regClosesAt.72h": "72 horas", - "errors.allModePool": "La rotación de escenarios debe contener un escenario para cada modo competitivo si se usa \"Preseleccionado por los equipos - Todos los modos\"", "errors.bracketUrlRequired": "La URL del bracket es obligatoria", "errors.bracketProgressionRequired": "La progresión del bracket debe configurarse para los torneos", "labels.brackets": "Brackets", diff --git a/locales/es-ES/tournament.json b/locales/es-ES/tournament.json index 18ab47c08..954161288 100644 --- a/locales/es-ES/tournament.json +++ b/locales/es-ES/tournament.json @@ -68,7 +68,16 @@ "pre.googleForm.header": "", "pre.googleForm.link": "", "pre.googleForm.footer": "", - "rules.tiebreakerMapPool": "", + "rules.teamPick.picks": "", + "rules.teamPick.pool.SENDOUQ": "", + "rules.teamPick.pool.ALL": "", + "rules.teamPick.pool.CUSTOM": "", + "rules.teamPick.neutral": "", + "mapList.rerollModeOrder": "", + "mapList.rerollAllMaps": "", + "mapList.teamsPick": "", + "mapList.customFlow": "", + "rules.teamPick.stageRepeat": "", "team.label": "Equipo", "team.teamPage": "", "team.vs": "", @@ -80,7 +89,7 @@ "pickInfo.team.counterpick": "{{team}} hizo contraselección", "pickInfo.pickedBy": "Elegido por {{teamName}}", "pickInfo.counterpickedBy": "Contrarrestado por {{teamName}}", - "pickInfo.tiebreaker": "Desempate", + "pickInfo.random": "", "pickInfo.both": "Ambos eligieron", "pickInfo.default": "Elección de la comunidad", "pickInfo.counterpick": "Contraselección", diff --git a/locales/es-US/calendar.json b/locales/es-US/calendar.json index 5e6fab06c..73b3864af 100644 --- a/locales/es-US/calendar.json +++ b/locales/es-US/calendar.json @@ -20,6 +20,8 @@ "forms.trophy": "Premio de trofeo", "forms.trophy.placeholder": "Elige un trofeo como premio", "forms.mapPool": "Grupo de mapas", + "forms.teamPick.poolShortfall": "", + "forms.teamPick.poolOk": "", "forms.reportResultsHeader": "Anunciando resultados para {{eventName}}", "forms.reportResultsInfo": "Tú decides cuántos resultados reportar. Puede ser solo el equipo ganador, los 3 primeros o lo que quieras.", "forms.team.placing": "Lugar", diff --git a/locales/es-US/common.json b/locales/es-US/common.json index 05dba106f..c20331ca7 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -206,7 +206,6 @@ "maps.halfSz": "50% Pintazonas", "maps.mapPool": "Grupo de escenarios", "maps.tournamentMaplist": "Crear lista de escenarios para torneo (maps.iplabs.ink)", - "maps.tieBreakerMapPool": "Grupo de escenarios para desempate", "maps.template": "Plantilla", "maps.template.none": "Ninguno", "maps.template.presets": "Preajustes", @@ -214,14 +213,10 @@ "maps.template.preset.ANARCHY": "Estilos de combate caótico", "maps.template.preset.ALL": "Todos los estilos", "maps.template.preset.onlyMode": "Solo {{modeName}}", - "maps.validation.PICKING": "Elige un escenario por estilo", "maps.validation.STAGE_REPEAT_IN_SAME_MODE": "No se permite un escenario dos veces en el mismo estilo", - "maps.validation.NOT_ONE_MAP_PER_MODE": "Elige solo un escenario por estilo", - "maps.validation.MAP_REPEATED": "No se permite el mismo escenario más de una vez", - "maps.validation.MODE_REPEATED": "No se permite el mismo modo más de una vez", "maps.validation.TOO_MUCH_STAGE_REPEAT": "No se permite este escenario más de {{maxStageRepeat}} veces", - "maps.validation.INCLUDES_BANNED": "No se permiten escenarios prohibidos", - "maps.validation.INCLUDES_TIEBREAKER": "No se permite el escenario para desempate", + "maps.validation.NOT_IN_POOL": "", + "maps.banned": "", "maps.validation.VALID": "¡Grupo de escenarios OK!", "results": "Resultados", "forms.name": "Nombre", diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json index 7fa0f494e..dc9e2c5ba 100644 --- a/locales/es-US/forms.json +++ b/locales/es-US/forms.json @@ -146,6 +146,9 @@ "errors.staffCannotBeAuthor": "El autor del torneo ya es organizador", "errors.divBothOrNeither": "Deben establecerse tanto la div. mínima como la máxima, o ninguna", "errors.invalidMapPool": "Rotación de escenarios no válida", + "errors.teamPick.noModes": "", + "errors.teamPick.poolTooSmall": "", + "errors.teamPick.countOutOfRange": "", "errors.scrimTournamentRequired": "Por favor, selecciona un torneo", "errors.tournamentMustBeSelected": "Debe seleccionarse un torneo cuando los mapas son de torneo", "errors.visibilityMustBeDifferent": "La visibilidad de 'no encontrado' debe ser diferente a la visibilidad base", @@ -349,6 +352,16 @@ "labels.draft": "Borrador", "labels.requireSendouQ": "Requerir participación en SendouQ", "labels.mapPickingStyle": "Estilo de selección de escenario", + "labels.teamPickModes": "", + "labels.teamPickCounts": "", + "bottomTexts.teamPickCounts": "", + "labels.teamPickPool": "", + "options.mapPickingStyle.TO": "", + "options.mapPickingStyle.AUTO": "", + "options.teamPickPool.SENDOUQ": "", + "options.teamPickPool.ALL": "", + "options.teamPickPool.CUSTOM": "", + "bottomTexts.teamPickReset": "", "bottomTexts.regClosesAt": "Todos los horarios son relativos a la hora de inicio del torneo. Ej. \"30 minutos\" significa \"30 minutos antes del inicio del evento\". Una vez cerrada la inscripción, solo los TOs pueden cambiar los rosters.", "bottomTexts.maxTeamSize": "Número máximo de jugadores que pueden registrarse por equipo. No aplica a los organizadores del torneo. El valor por defecto es 6.", "bottomTexts.avatarValidation": "Ten en cuenta que para los no mecenas hay un proceso de validación antes de que se muestre el avatar.", @@ -361,12 +374,6 @@ "bottomTexts.test": "Los torneos de prueba no aparecen en el calendario, no envían notificaciones a los jugadores y no se mostrarán en los perfiles ni resultados de los jugadores", "bottomTexts.draftInfo": "Los torneos borrador están ocultos y solo son visibles para los organizadores. El torneo debe abrirse (desactivando esta opción) antes de poder empezar cualquier bracket.", "bottomTexts.requireSendouQ": "Los jugadores deben haber jugado suficientes combates de SendouQ esta temporada para inscribirse", - "options.toToolsMode.ALL": "Preseleccionado por los equipos - Todos los modos", - "options.toToolsMode.SZ": "Preseleccionado por los equipos - Solo Pintazonas", - "options.toToolsMode.TC": "Preseleccionado por los equipos - Solo Torreón", - "options.toToolsMode.RM": "Preseleccionado por los equipos - Solo Pez dorado", - "options.toToolsMode.CB": "Preseleccionado por los equipos - Solo Asalto Almeja", - "options.toToolsMode.TO": "Seleccionado por el TO", "options.regClosesAt.0": "A la hora de inicio", "options.regClosesAt.5min": "5 minutos", "options.regClosesAt.10min": "10 minutos", @@ -382,7 +389,6 @@ "options.regClosesAt.24h": "24 horas", "options.regClosesAt.48h": "48 horas", "options.regClosesAt.72h": "72 horas", - "errors.allModePool": "La rotación de escenarios debe contener un escenario para cada modo competitivo si se usa \"Preseleccionado por los equipos - Todos los modos\"", "errors.bracketUrlRequired": "La URL del bracket es obligatoria", "errors.bracketProgressionRequired": "La progresión del bracket debe configurarse para los torneos", "labels.brackets": "Brackets", diff --git a/locales/es-US/tournament.json b/locales/es-US/tournament.json index 3310486bd..f9511cbb3 100644 --- a/locales/es-US/tournament.json +++ b/locales/es-US/tournament.json @@ -68,7 +68,16 @@ "pre.googleForm.header": "", "pre.googleForm.link": "", "pre.googleForm.footer": "", - "rules.tiebreakerMapPool": "", + "rules.teamPick.picks": "", + "rules.teamPick.pool.SENDOUQ": "", + "rules.teamPick.pool.ALL": "", + "rules.teamPick.pool.CUSTOM": "", + "rules.teamPick.neutral": "", + "mapList.rerollModeOrder": "", + "mapList.rerollAllMaps": "", + "mapList.teamsPick": "", + "mapList.customFlow": "", + "rules.teamPick.stageRepeat": "", "team.label": "Equipo", "team.teamPage": "", "team.vs": "", @@ -80,7 +89,7 @@ "pickInfo.team.counterpick": "{{team}} hizo contraselección", "pickInfo.pickedBy": "Elegido por {{teamName}}", "pickInfo.counterpickedBy": "Contrarrestado por {{teamName}}", - "pickInfo.tiebreaker": "Desempate", + "pickInfo.random": "", "pickInfo.both": "Ambos eligieron", "pickInfo.default": "Elección de la comunidad", "pickInfo.counterpick": "Contraselección", diff --git a/locales/fr-CA/calendar.json b/locales/fr-CA/calendar.json index 5ad0f8789..fa7633238 100644 --- a/locales/fr-CA/calendar.json +++ b/locales/fr-CA/calendar.json @@ -20,6 +20,8 @@ "forms.trophy": "", "forms.trophy.placeholder": "", "forms.mapPool": "Stages disponibles", + "forms.teamPick.poolShortfall": "", + "forms.teamPick.poolOk": "", "forms.reportResultsHeader": "Déclarer les résultats pour {{eventName}}", "forms.reportResultsInfo": "Les résultats déclarés peuvent être personnalisés. Vous pouvez déclarer uniquement le gagnant, le top 3, ou tout ce que vous décidez.", "forms.team.placing": "Position", diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index 963937079..62797636e 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -206,7 +206,6 @@ "maps.halfSz": "50% DdZ", "maps.mapPool": "Pool de stages", "maps.tournamentMaplist": "Créer une liste de stages de tournoi (maps.iplabs.ink)", - "maps.tieBreakerMapPool": "Pool de stages de tie breaker", "maps.template": "Modèle", "maps.template.none": "Aucun", "maps.template.presets": "Préréglages", @@ -214,14 +213,10 @@ "maps.template.preset.ANARCHY": "Modes Anarchie", "maps.template.preset.ALL": "Tous les Modes", "maps.template.preset.onlyMode": "{{modeName}} uniquement", - "maps.validation.PICKING": "Choissisez un stage par mode", "maps.validation.STAGE_REPEAT_IN_SAME_MODE": "Impossible de choisir le même stage deux fois dans le même mode", - "maps.validation.NOT_ONE_MAP_PER_MODE": "Ne choissisez qu'un stage par mode", - "maps.validation.MAP_REPEATED": "Impossible de choisir le même stage plus d'une fois", - "maps.validation.MODE_REPEATED": "Impossible de choisir le même mode plus d'une fois", "maps.validation.TOO_MUCH_STAGE_REPEAT": "Impossible de choisir le même stage plus de {{maxStageRepeat}} fois", - "maps.validation.INCLUDES_BANNED": "", - "maps.validation.INCLUDES_TIEBREAKER": "", + "maps.validation.NOT_IN_POOL": "", + "maps.banned": "", "maps.validation.VALID": "Liste de stages valide !", "results": "Résultats", "forms.name": "Nom", diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json index 2d453f0d8..82100c66b 100644 --- a/locales/fr-CA/forms.json +++ b/locales/fr-CA/forms.json @@ -146,6 +146,9 @@ "errors.staffCannotBeAuthor": "", "errors.divBothOrNeither": "", "errors.invalidMapPool": "", + "errors.teamPick.noModes": "", + "errors.teamPick.poolTooSmall": "", + "errors.teamPick.countOutOfRange": "", "errors.scrimTournamentRequired": "", "errors.tournamentMustBeSelected": "", "errors.visibilityMustBeDifferent": "", @@ -349,6 +352,16 @@ "labels.draft": "", "labels.requireSendouQ": "", "labels.mapPickingStyle": "", + "labels.teamPickModes": "", + "labels.teamPickCounts": "", + "bottomTexts.teamPickCounts": "", + "labels.teamPickPool": "", + "options.mapPickingStyle.TO": "", + "options.mapPickingStyle.AUTO": "", + "options.teamPickPool.SENDOUQ": "", + "options.teamPickPool.ALL": "", + "options.teamPickPool.CUSTOM": "", + "bottomTexts.teamPickReset": "", "bottomTexts.regClosesAt": "", "bottomTexts.maxTeamSize": "", "bottomTexts.avatarValidation": "", @@ -361,12 +374,6 @@ "bottomTexts.test": "", "bottomTexts.draftInfo": "", "bottomTexts.requireSendouQ": "", - "options.toToolsMode.ALL": "", - "options.toToolsMode.SZ": "", - "options.toToolsMode.TC": "", - "options.toToolsMode.RM": "", - "options.toToolsMode.CB": "", - "options.toToolsMode.TO": "", "options.regClosesAt.0": "", "options.regClosesAt.5min": "", "options.regClosesAt.10min": "", @@ -382,7 +389,6 @@ "options.regClosesAt.24h": "", "options.regClosesAt.48h": "", "options.regClosesAt.72h": "", - "errors.allModePool": "", "errors.bracketUrlRequired": "", "errors.bracketProgressionRequired": "", "labels.brackets": "", diff --git a/locales/fr-CA/tournament.json b/locales/fr-CA/tournament.json index 53e667c6b..da308f57a 100644 --- a/locales/fr-CA/tournament.json +++ b/locales/fr-CA/tournament.json @@ -68,7 +68,16 @@ "pre.googleForm.header": "", "pre.googleForm.link": "", "pre.googleForm.footer": "", - "rules.tiebreakerMapPool": "", + "rules.teamPick.picks": "", + "rules.teamPick.pool.SENDOUQ": "", + "rules.teamPick.pool.ALL": "", + "rules.teamPick.pool.CUSTOM": "", + "rules.teamPick.neutral": "", + "mapList.rerollModeOrder": "", + "mapList.rerollAllMaps": "", + "mapList.teamsPick": "", + "mapList.customFlow": "", + "rules.teamPick.stageRepeat": "", "team.label": "Équipe", "team.teamPage": "", "team.vs": "", @@ -80,7 +89,7 @@ "pickInfo.team.counterpick": "", "pickInfo.pickedBy": "", "pickInfo.counterpickedBy": "", - "pickInfo.tiebreaker": "Tiebreaker", + "pickInfo.random": "", "pickInfo.both": "Les deux ont choisi", "pickInfo.default": "", "pickInfo.counterpick": "", diff --git a/locales/fr-EU/calendar.json b/locales/fr-EU/calendar.json index 5ad0f8789..fa7633238 100644 --- a/locales/fr-EU/calendar.json +++ b/locales/fr-EU/calendar.json @@ -20,6 +20,8 @@ "forms.trophy": "", "forms.trophy.placeholder": "", "forms.mapPool": "Stages disponibles", + "forms.teamPick.poolShortfall": "", + "forms.teamPick.poolOk": "", "forms.reportResultsHeader": "Déclarer les résultats pour {{eventName}}", "forms.reportResultsInfo": "Les résultats déclarés peuvent être personnalisés. Vous pouvez déclarer uniquement le gagnant, le top 3, ou tout ce que vous décidez.", "forms.team.placing": "Position", diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index d22f28a8d..b0400ea44 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -206,7 +206,6 @@ "maps.halfSz": "50% DdZ", "maps.mapPool": "Pool de stages", "maps.tournamentMaplist": "Créer une liste de stages de tournoi (maps.iplabs.ink)", - "maps.tieBreakerMapPool": "Pool de stages de tie breaker", "maps.template": "Modèle", "maps.template.none": "Aucun", "maps.template.presets": "Préréglages", @@ -214,14 +213,10 @@ "maps.template.preset.ANARCHY": "Modes Anarchie", "maps.template.preset.ALL": "Tous les Modes", "maps.template.preset.onlyMode": "{{modeName}} uniquement", - "maps.validation.PICKING": "Choisissez un stage par mode", "maps.validation.STAGE_REPEAT_IN_SAME_MODE": "Impossible de choisir le même stage deux fois dans le même mode", - "maps.validation.NOT_ONE_MAP_PER_MODE": "Ne choisissez qu'un stage par mode", - "maps.validation.MAP_REPEATED": "Impossible de choisir le même stage plus d'une fois", - "maps.validation.MODE_REPEATED": "Impossible de choisir le même mode plus d'une fois", "maps.validation.TOO_MUCH_STAGE_REPEAT": "Impossible de choisir le même stage plus de {{maxStageRepeat}} fois", - "maps.validation.INCLUDES_BANNED": "Vous ne pouvez pas choisir des stages bannis", - "maps.validation.INCLUDES_TIEBREAKER": "Vous ne pouvez pas choisir le stage décisif", + "maps.validation.NOT_IN_POOL": "", + "maps.banned": "", "maps.validation.VALID": "Liste de stages valide !", "results": "Résultats", "forms.name": "Nom", diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json index 0aad25ab0..f1e769e3d 100644 --- a/locales/fr-EU/forms.json +++ b/locales/fr-EU/forms.json @@ -146,6 +146,9 @@ "errors.staffCannotBeAuthor": "", "errors.divBothOrNeither": "", "errors.invalidMapPool": "", + "errors.teamPick.noModes": "", + "errors.teamPick.poolTooSmall": "", + "errors.teamPick.countOutOfRange": "", "errors.scrimTournamentRequired": "", "errors.tournamentMustBeSelected": "", "errors.visibilityMustBeDifferent": "", @@ -349,6 +352,16 @@ "labels.draft": "", "labels.requireSendouQ": "", "labels.mapPickingStyle": "", + "labels.teamPickModes": "", + "labels.teamPickCounts": "", + "bottomTexts.teamPickCounts": "", + "labels.teamPickPool": "", + "options.mapPickingStyle.TO": "", + "options.mapPickingStyle.AUTO": "", + "options.teamPickPool.SENDOUQ": "", + "options.teamPickPool.ALL": "", + "options.teamPickPool.CUSTOM": "", + "bottomTexts.teamPickReset": "", "bottomTexts.regClosesAt": "", "bottomTexts.maxTeamSize": "", "bottomTexts.avatarValidation": "", @@ -361,12 +374,6 @@ "bottomTexts.test": "", "bottomTexts.draftInfo": "", "bottomTexts.requireSendouQ": "", - "options.toToolsMode.ALL": "", - "options.toToolsMode.SZ": "", - "options.toToolsMode.TC": "", - "options.toToolsMode.RM": "", - "options.toToolsMode.CB": "", - "options.toToolsMode.TO": "", "options.regClosesAt.0": "", "options.regClosesAt.5min": "", "options.regClosesAt.10min": "", @@ -382,7 +389,6 @@ "options.regClosesAt.24h": "", "options.regClosesAt.48h": "", "options.regClosesAt.72h": "", - "errors.allModePool": "", "errors.bracketUrlRequired": "", "errors.bracketProgressionRequired": "", "labels.brackets": "", diff --git a/locales/fr-EU/tournament.json b/locales/fr-EU/tournament.json index 0694fb676..1de906c60 100644 --- a/locales/fr-EU/tournament.json +++ b/locales/fr-EU/tournament.json @@ -68,7 +68,16 @@ "pre.googleForm.header": "", "pre.googleForm.link": "", "pre.googleForm.footer": "", - "rules.tiebreakerMapPool": "", + "rules.teamPick.picks": "", + "rules.teamPick.pool.SENDOUQ": "", + "rules.teamPick.pool.ALL": "", + "rules.teamPick.pool.CUSTOM": "", + "rules.teamPick.neutral": "", + "mapList.rerollModeOrder": "", + "mapList.rerollAllMaps": "", + "mapList.teamsPick": "", + "mapList.customFlow": "", + "rules.teamPick.stageRepeat": "", "team.label": "Équipe", "team.teamPage": "", "team.vs": "", @@ -80,7 +89,7 @@ "pickInfo.team.counterpick": "", "pickInfo.pickedBy": "", "pickInfo.counterpickedBy": "", - "pickInfo.tiebreaker": "Manche décisive", + "pickInfo.random": "", "pickInfo.both": "Les deux ont choisi", "pickInfo.default": "", "pickInfo.counterpick": "Counterpick", diff --git a/locales/he/calendar.json b/locales/he/calendar.json index 08fa56890..ea52e969d 100644 --- a/locales/he/calendar.json +++ b/locales/he/calendar.json @@ -20,6 +20,8 @@ "forms.trophy": "", "forms.trophy.placeholder": "", "forms.mapPool": "מאגר מפות", + "forms.teamPick.poolShortfall": "", + "forms.teamPick.poolOk": "", "forms.reportResultsHeader": "דיווח תוצאות עבור {{eventName}}", "forms.reportResultsInfo": "אתם בוחרים כמה תוצאות לדווח. זה יכול להיות רק הצוות המנצח, הטופ מ-3 או כל מה שתחליטו.", "forms.team.placing": "מיקום", diff --git a/locales/he/common.json b/locales/he/common.json index 8850fc218..56d50745f 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -206,7 +206,6 @@ "maps.halfSz": "50% SZ", "maps.mapPool": "מאגר מפות", "maps.tournamentMaplist": "יצירת רשימת מפות עבור טורנירים (maps.iplabs.ink)", - "maps.tieBreakerMapPool": "מאגר מפות לשובר שיוויון", "maps.template": "תבנית", "maps.template.none": "ללא", "maps.template.presets": "הגדרות קבועות מראש", @@ -214,14 +213,10 @@ "maps.template.preset.ANARCHY": "מודים של אנרכיה", "maps.template.preset.ALL": "כל המודים", "maps.template.preset.onlyMode": "רק {{modeName}}", - "maps.validation.PICKING": "בחרו מפה אחת לכל מוד", "maps.validation.STAGE_REPEAT_IN_SAME_MODE": "לא ניתן לבחור את אותה מפה פעמיים באותו מוד", - "maps.validation.NOT_ONE_MAP_PER_MODE": "בחרו רק מפה אחת לכל מוד", - "maps.validation.MAP_REPEATED": "לא ניתן לבחור את אותה מפה יותר מפעם אחת", - "maps.validation.MODE_REPEATED": "לא ניתו לבחור את אותו מוד יותר מפעם אחת", "maps.validation.TOO_MUCH_STAGE_REPEAT": "לא ניתן לבחור מפה יותר מ-{{maxStageRepeat}} פעמים", - "maps.validation.INCLUDES_BANNED": "", - "maps.validation.INCLUDES_TIEBREAKER": "", + "maps.validation.NOT_IN_POOL": "", + "maps.banned": "", "maps.validation.VALID": "מאגר המפות בסדר!", "results": "תוצאות", "forms.name": "שם", diff --git a/locales/he/forms.json b/locales/he/forms.json index 98da2c0fc..3cb59362d 100644 --- a/locales/he/forms.json +++ b/locales/he/forms.json @@ -146,6 +146,9 @@ "errors.staffCannotBeAuthor": "", "errors.divBothOrNeither": "", "errors.invalidMapPool": "", + "errors.teamPick.noModes": "", + "errors.teamPick.poolTooSmall": "", + "errors.teamPick.countOutOfRange": "", "errors.scrimTournamentRequired": "", "errors.tournamentMustBeSelected": "", "errors.visibilityMustBeDifferent": "", @@ -349,6 +352,16 @@ "labels.draft": "", "labels.requireSendouQ": "", "labels.mapPickingStyle": "", + "labels.teamPickModes": "", + "labels.teamPickCounts": "", + "bottomTexts.teamPickCounts": "", + "labels.teamPickPool": "", + "options.mapPickingStyle.TO": "", + "options.mapPickingStyle.AUTO": "", + "options.teamPickPool.SENDOUQ": "", + "options.teamPickPool.ALL": "", + "options.teamPickPool.CUSTOM": "", + "bottomTexts.teamPickReset": "", "bottomTexts.regClosesAt": "", "bottomTexts.maxTeamSize": "", "bottomTexts.avatarValidation": "", @@ -361,12 +374,6 @@ "bottomTexts.test": "", "bottomTexts.draftInfo": "", "bottomTexts.requireSendouQ": "", - "options.toToolsMode.ALL": "", - "options.toToolsMode.SZ": "", - "options.toToolsMode.TC": "", - "options.toToolsMode.RM": "", - "options.toToolsMode.CB": "", - "options.toToolsMode.TO": "", "options.regClosesAt.0": "", "options.regClosesAt.5min": "", "options.regClosesAt.10min": "", @@ -382,7 +389,6 @@ "options.regClosesAt.24h": "", "options.regClosesAt.48h": "", "options.regClosesAt.72h": "", - "errors.allModePool": "", "errors.bracketUrlRequired": "", "errors.bracketProgressionRequired": "", "labels.brackets": "", diff --git a/locales/he/tournament.json b/locales/he/tournament.json index 467fa67ca..be8816b57 100644 --- a/locales/he/tournament.json +++ b/locales/he/tournament.json @@ -68,7 +68,16 @@ "pre.googleForm.header": "", "pre.googleForm.link": "", "pre.googleForm.footer": "", - "rules.tiebreakerMapPool": "", + "rules.teamPick.picks": "", + "rules.teamPick.pool.SENDOUQ": "", + "rules.teamPick.pool.ALL": "", + "rules.teamPick.pool.CUSTOM": "", + "rules.teamPick.neutral": "", + "mapList.rerollModeOrder": "", + "mapList.rerollAllMaps": "", + "mapList.teamsPick": "", + "mapList.customFlow": "", + "rules.teamPick.stageRepeat": "", "team.label": "צוות", "team.teamPage": "", "team.vs": "", @@ -80,7 +89,7 @@ "pickInfo.team.counterpick": "", "pickInfo.pickedBy": "", "pickInfo.counterpickedBy": "", - "pickInfo.tiebreaker": "שובר שיוויון", + "pickInfo.random": "", "pickInfo.both": "שניהם בחרו", "pickInfo.default": "", "pickInfo.counterpick": "", diff --git a/locales/it/calendar.json b/locales/it/calendar.json index 80d4b2a5c..6720089e7 100644 --- a/locales/it/calendar.json +++ b/locales/it/calendar.json @@ -20,6 +20,8 @@ "forms.trophy": "", "forms.trophy.placeholder": "", "forms.mapPool": "Pool di scenari", + "forms.teamPick.poolShortfall": "", + "forms.teamPick.poolOk": "", "forms.reportResultsHeader": "Reporting results for {{eventName}}", "forms.reportResultsInfo": "Puoi scegliere quanti risultati vuoi riportare, se fare solo la squadra vincitrice, la top 3 o un'altra combinazione.", "forms.team.placing": "Risultato", diff --git a/locales/it/common.json b/locales/it/common.json index 7d3e716ec..d7b7d3520 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -206,7 +206,6 @@ "maps.halfSz": "50% ZS", "maps.mapPool": "Pool di scenari", "maps.tournamentMaplist": "Crea lista scenari per tornei (maps.iplabs.ink)", - "maps.tieBreakerMapPool": "Pool scenari per lo spareggio", "maps.template": "Template", "maps.template.none": "Nessuno", "maps.template.presets": "Preset", @@ -214,14 +213,10 @@ "maps.template.preset.ANARCHY": "Modalità anarchia", "maps.template.preset.ALL": "Tutte le modalità", "maps.template.preset.onlyMode": "Solo {{modeName}}", - "maps.validation.PICKING": "Scegli uno scenario per modalità", "maps.validation.STAGE_REPEAT_IN_SAME_MODE": "Non puoi scegliere la stessa modalità due volte nella stessa modalità", - "maps.validation.NOT_ONE_MAP_PER_MODE": "Scegli solo uno scenario per modalità", - "maps.validation.MAP_REPEATED": "Non puoi selezionare lo stesso scenario più di una volta", - "maps.validation.MODE_REPEATED": "Non puoi selezionare la stessa modalità più di una volta", "maps.validation.TOO_MUCH_STAGE_REPEAT": "Non puoi selezionare uno scenario più di {{maxStageRepeat}} volte", - "maps.validation.INCLUDES_BANNED": "Non si possono scegliere mappe bannate", - "maps.validation.INCLUDES_TIEBREAKER": "Non può venir scelta la mappa per lo spareggio", + "maps.validation.NOT_IN_POOL": "", + "maps.banned": "", "maps.validation.VALID": "Pool scenari OK!", "results": "Risultati", "forms.name": "Nome", diff --git a/locales/it/forms.json b/locales/it/forms.json index aa95718cc..58d7132bf 100644 --- a/locales/it/forms.json +++ b/locales/it/forms.json @@ -146,6 +146,9 @@ "errors.staffCannotBeAuthor": "", "errors.divBothOrNeither": "", "errors.invalidMapPool": "", + "errors.teamPick.noModes": "", + "errors.teamPick.poolTooSmall": "", + "errors.teamPick.countOutOfRange": "", "errors.scrimTournamentRequired": "", "errors.tournamentMustBeSelected": "", "errors.visibilityMustBeDifferent": "", @@ -349,6 +352,16 @@ "labels.draft": "", "labels.requireSendouQ": "", "labels.mapPickingStyle": "", + "labels.teamPickModes": "", + "labels.teamPickCounts": "", + "bottomTexts.teamPickCounts": "", + "labels.teamPickPool": "", + "options.mapPickingStyle.TO": "", + "options.mapPickingStyle.AUTO": "", + "options.teamPickPool.SENDOUQ": "", + "options.teamPickPool.ALL": "", + "options.teamPickPool.CUSTOM": "", + "bottomTexts.teamPickReset": "", "bottomTexts.regClosesAt": "", "bottomTexts.maxTeamSize": "", "bottomTexts.avatarValidation": "", @@ -361,12 +374,6 @@ "bottomTexts.test": "", "bottomTexts.draftInfo": "", "bottomTexts.requireSendouQ": "", - "options.toToolsMode.ALL": "", - "options.toToolsMode.SZ": "", - "options.toToolsMode.TC": "", - "options.toToolsMode.RM": "", - "options.toToolsMode.CB": "", - "options.toToolsMode.TO": "", "options.regClosesAt.0": "", "options.regClosesAt.5min": "", "options.regClosesAt.10min": "", @@ -382,7 +389,6 @@ "options.regClosesAt.24h": "", "options.regClosesAt.48h": "", "options.regClosesAt.72h": "", - "errors.allModePool": "", "errors.bracketUrlRequired": "", "errors.bracketProgressionRequired": "", "labels.brackets": "", diff --git a/locales/it/tournament.json b/locales/it/tournament.json index ede389d03..c3ec94aa8 100644 --- a/locales/it/tournament.json +++ b/locales/it/tournament.json @@ -68,7 +68,16 @@ "pre.googleForm.header": "", "pre.googleForm.link": "", "pre.googleForm.footer": "", - "rules.tiebreakerMapPool": "", + "rules.teamPick.picks": "", + "rules.teamPick.pool.SENDOUQ": "", + "rules.teamPick.pool.ALL": "", + "rules.teamPick.pool.CUSTOM": "", + "rules.teamPick.neutral": "", + "mapList.rerollModeOrder": "", + "mapList.rerollAllMaps": "", + "mapList.teamsPick": "", + "mapList.customFlow": "", + "rules.teamPick.stageRepeat": "", "team.label": "Squadra", "team.teamPage": "", "team.vs": "", @@ -80,7 +89,7 @@ "pickInfo.team.counterpick": "", "pickInfo.pickedBy": "", "pickInfo.counterpickedBy": "", - "pickInfo.tiebreaker": "Spareggio", + "pickInfo.random": "", "pickInfo.both": "Scelto da entrambi", "pickInfo.default": "", "pickInfo.counterpick": "Counterpick", diff --git a/locales/ja/calendar.json b/locales/ja/calendar.json index cb677fc8d..67c0e0510 100644 --- a/locales/ja/calendar.json +++ b/locales/ja/calendar.json @@ -16,6 +16,8 @@ "forms.trophy": "", "forms.trophy.placeholder": "", "forms.mapPool": "選択可能なステージ", + "forms.teamPick.poolShortfall": "", + "forms.teamPick.poolOk": "", "forms.reportResultsHeader": "{{eventName}} の結果報告", "forms.reportResultsInfo": "報告する結果の数を選択します。勝者のみ、トップ3など自由に決定できます。", "forms.team.placing": "順位", diff --git a/locales/ja/common.json b/locales/ja/common.json index 92d93f522..ee2d21cc2 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -206,7 +206,6 @@ "maps.halfSz": "半分エリア", "maps.mapPool": "選択可能なステージ", "maps.tournamentMaplist": "大会ステージリストを作る (maps.iplabs.ink)", - "maps.tieBreakerMapPool": "タイブレーク用マップリスト", "maps.template": "テンプレート", "maps.template.none": "なし", "maps.template.presets": "プリセット", @@ -214,14 +213,10 @@ "maps.template.preset.ANARCHY": "バンカラマッチルール", "maps.template.preset.ALL": "すべてのルール", "maps.template.preset.onlyMode": "{{modeName}} のみ", - "maps.validation.PICKING": "ステージごとにルールを選択する", "maps.validation.STAGE_REPEAT_IN_SAME_MODE": "同じルールで1回以上同じマップを選択できません", - "maps.validation.NOT_ONE_MAP_PER_MODE": "1ルール1ステージで選択する", - "maps.validation.MAP_REPEATED": "同じステージを1回以上選択できません", - "maps.validation.MODE_REPEATED": "同じルールを1回以上選択できません", "maps.validation.TOO_MUCH_STAGE_REPEAT": "同じステージを {{maxStageRepeat}} 回以上選択できません", - "maps.validation.INCLUDES_BANNED": "禁止ステージを選ぶことはできません", - "maps.validation.INCLUDES_TIEBREAKER": "タイブレーク(引き分けの場合)用のステージは選択できません", + "maps.validation.NOT_IN_POOL": "", + "maps.banned": "", "maps.validation.VALID": "ステージリストに問題はありません!", "results": "結果", "forms.name": "名前", diff --git a/locales/ja/forms.json b/locales/ja/forms.json index ffebf8a4f..d1fd9782f 100644 --- a/locales/ja/forms.json +++ b/locales/ja/forms.json @@ -146,6 +146,9 @@ "errors.staffCannotBeAuthor": "", "errors.divBothOrNeither": "", "errors.invalidMapPool": "", + "errors.teamPick.noModes": "", + "errors.teamPick.poolTooSmall": "", + "errors.teamPick.countOutOfRange": "", "errors.scrimTournamentRequired": "", "errors.tournamentMustBeSelected": "", "errors.visibilityMustBeDifferent": "", @@ -349,6 +352,16 @@ "labels.draft": "", "labels.requireSendouQ": "", "labels.mapPickingStyle": "", + "labels.teamPickModes": "", + "labels.teamPickCounts": "", + "bottomTexts.teamPickCounts": "", + "labels.teamPickPool": "", + "options.mapPickingStyle.TO": "", + "options.mapPickingStyle.AUTO": "", + "options.teamPickPool.SENDOUQ": "", + "options.teamPickPool.ALL": "", + "options.teamPickPool.CUSTOM": "", + "bottomTexts.teamPickReset": "", "bottomTexts.regClosesAt": "", "bottomTexts.maxTeamSize": "", "bottomTexts.avatarValidation": "", @@ -361,12 +374,6 @@ "bottomTexts.test": "", "bottomTexts.draftInfo": "", "bottomTexts.requireSendouQ": "", - "options.toToolsMode.ALL": "", - "options.toToolsMode.SZ": "", - "options.toToolsMode.TC": "", - "options.toToolsMode.RM": "", - "options.toToolsMode.CB": "", - "options.toToolsMode.TO": "", "options.regClosesAt.0": "", "options.regClosesAt.5min": "", "options.regClosesAt.10min": "", @@ -382,7 +389,6 @@ "options.regClosesAt.24h": "", "options.regClosesAt.48h": "", "options.regClosesAt.72h": "", - "errors.allModePool": "", "errors.bracketUrlRequired": "", "errors.bracketProgressionRequired": "", "labels.brackets": "", diff --git a/locales/ja/tournament.json b/locales/ja/tournament.json index 0db3f2a71..8c413a605 100644 --- a/locales/ja/tournament.json +++ b/locales/ja/tournament.json @@ -68,7 +68,16 @@ "pre.googleForm.header": "", "pre.googleForm.link": "", "pre.googleForm.footer": "", - "rules.tiebreakerMapPool": "", + "rules.teamPick.picks": "", + "rules.teamPick.pool.SENDOUQ": "", + "rules.teamPick.pool.ALL": "", + "rules.teamPick.pool.CUSTOM": "", + "rules.teamPick.neutral": "", + "mapList.rerollModeOrder": "", + "mapList.rerollAllMaps": "", + "mapList.teamsPick": "", + "mapList.customFlow": "", + "rules.teamPick.stageRepeat": "", "team.label": "チーム", "team.teamPage": "", "team.vs": "", @@ -80,7 +89,7 @@ "pickInfo.team.counterpick": "", "pickInfo.pickedBy": "", "pickInfo.counterpickedBy": "", - "pickInfo.tiebreaker": "タイブレイカー", + "pickInfo.random": "", "pickInfo.both": "両者選択", "pickInfo.default": "", "pickInfo.counterpick": "順番に選ぶ", diff --git a/locales/ko/calendar.json b/locales/ko/calendar.json index f937d2a6a..51fc28d55 100644 --- a/locales/ko/calendar.json +++ b/locales/ko/calendar.json @@ -14,6 +14,8 @@ "forms.trophy": "", "forms.trophy.placeholder": "", "forms.mapPool": "", + "forms.teamPick.poolShortfall": "", + "forms.teamPick.poolOk": "", "forms.reportResultsHeader": "{{eventName}}의 결과 보고", "forms.reportResultsInfo": "얼마나 많은 결과를 보고할지 선택할 수 있습니다.우승팀만일 수도 있고 탑3 또는 원하시는대로.", "forms.team.placing": "순위", diff --git a/locales/ko/common.json b/locales/ko/common.json index 78e4ae296..33e713d27 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -206,7 +206,6 @@ "maps.halfSz": "에어리어 50%", "maps.mapPool": "맵 풀", "maps.tournamentMaplist": "대회 맵 목록 생성 (maps.iplabs.ink)", - "maps.tieBreakerMapPool": "타이브레이커 맵 풀", "maps.template": "템플릿", "maps.template.none": "없음", "maps.template.presets": "프리셋", @@ -214,14 +213,10 @@ "maps.template.preset.ANARCHY": "랭크 룰", "maps.template.preset.ALL": "모든 룰", "maps.template.preset.onlyMode": "{{modeName}} 한정", - "maps.validation.PICKING": "모드 당 한 스테이지를 선택하세요.", "maps.validation.STAGE_REPEAT_IN_SAME_MODE": "같은 모드에서 같은 스테이지를 다시 선택할 수 없습니다.", - "maps.validation.NOT_ONE_MAP_PER_MODE": "모드 당 한 스테이지만 선택하세요.", - "maps.validation.MAP_REPEATED": "같은 스테이지를 다시 선택할 수 없습니다.", - "maps.validation.MODE_REPEATED": "같은 모드를 다시 선택할 수 없습니다.", "maps.validation.TOO_MUCH_STAGE_REPEAT": "{{maxStageRepeat}}회 이상 스테이지를 선택할 수 없습니다.", - "maps.validation.INCLUDES_BANNED": "", - "maps.validation.INCLUDES_TIEBREAKER": "", + "maps.validation.NOT_IN_POOL": "", + "maps.banned": "", "maps.validation.VALID": "Map pool ok!", "results": "결과", "forms.name": "이름", diff --git a/locales/ko/forms.json b/locales/ko/forms.json index e70d94376..b43d7b766 100644 --- a/locales/ko/forms.json +++ b/locales/ko/forms.json @@ -146,6 +146,9 @@ "errors.staffCannotBeAuthor": "", "errors.divBothOrNeither": "", "errors.invalidMapPool": "", + "errors.teamPick.noModes": "", + "errors.teamPick.poolTooSmall": "", + "errors.teamPick.countOutOfRange": "", "errors.scrimTournamentRequired": "", "errors.tournamentMustBeSelected": "", "errors.visibilityMustBeDifferent": "", @@ -349,6 +352,16 @@ "labels.draft": "", "labels.requireSendouQ": "", "labels.mapPickingStyle": "", + "labels.teamPickModes": "", + "labels.teamPickCounts": "", + "bottomTexts.teamPickCounts": "", + "labels.teamPickPool": "", + "options.mapPickingStyle.TO": "", + "options.mapPickingStyle.AUTO": "", + "options.teamPickPool.SENDOUQ": "", + "options.teamPickPool.ALL": "", + "options.teamPickPool.CUSTOM": "", + "bottomTexts.teamPickReset": "", "bottomTexts.regClosesAt": "", "bottomTexts.maxTeamSize": "", "bottomTexts.avatarValidation": "", @@ -361,12 +374,6 @@ "bottomTexts.test": "", "bottomTexts.draftInfo": "", "bottomTexts.requireSendouQ": "", - "options.toToolsMode.ALL": "", - "options.toToolsMode.SZ": "", - "options.toToolsMode.TC": "", - "options.toToolsMode.RM": "", - "options.toToolsMode.CB": "", - "options.toToolsMode.TO": "", "options.regClosesAt.0": "", "options.regClosesAt.5min": "", "options.regClosesAt.10min": "", @@ -382,7 +389,6 @@ "options.regClosesAt.24h": "", "options.regClosesAt.48h": "", "options.regClosesAt.72h": "", - "errors.allModePool": "", "errors.bracketUrlRequired": "", "errors.bracketProgressionRequired": "", "labels.brackets": "", diff --git a/locales/ko/tournament.json b/locales/ko/tournament.json index 1f92c28f4..0e40afa35 100644 --- a/locales/ko/tournament.json +++ b/locales/ko/tournament.json @@ -68,7 +68,16 @@ "pre.googleForm.header": "", "pre.googleForm.link": "", "pre.googleForm.footer": "", - "rules.tiebreakerMapPool": "", + "rules.teamPick.picks": "", + "rules.teamPick.pool.SENDOUQ": "", + "rules.teamPick.pool.ALL": "", + "rules.teamPick.pool.CUSTOM": "", + "rules.teamPick.neutral": "", + "mapList.rerollModeOrder": "", + "mapList.rerollAllMaps": "", + "mapList.teamsPick": "", + "mapList.customFlow": "", + "rules.teamPick.stageRepeat": "", "team.label": "", "team.teamPage": "", "team.vs": "", @@ -80,7 +89,7 @@ "pickInfo.team.counterpick": "", "pickInfo.pickedBy": "", "pickInfo.counterpickedBy": "", - "pickInfo.tiebreaker": "", + "pickInfo.random": "", "pickInfo.both": "", "pickInfo.default": "", "pickInfo.counterpick": "", diff --git a/locales/nl/calendar.json b/locales/nl/calendar.json index 9d54cdd01..a3d7db25d 100644 --- a/locales/nl/calendar.json +++ b/locales/nl/calendar.json @@ -18,6 +18,8 @@ "forms.trophy": "", "forms.trophy.placeholder": "", "forms.mapPool": "Beschikbare levels", + "forms.teamPick.poolShortfall": "", + "forms.teamPick.poolOk": "", "forms.reportResultsHeader": "Uitslagen doorgeven voor {{eventName}}", "forms.reportResultsInfo": "Je kunt zelf kiezen hoeveel uitslagen je wilt doorgeven. Het kan bijvoorbeeld alleen het winnende team zijn, maar ook de top 3, etc.", "forms.team.placing": "Plaatsing", diff --git a/locales/nl/common.json b/locales/nl/common.json index 89dc40ed5..1cf8d7c74 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -206,7 +206,6 @@ "maps.halfSz": "50% SZ", "maps.mapPool": "Beschikbare levels", "maps.tournamentMaplist": "Maak een toernooi levellijst (maps.iplabs.ink)", - "maps.tieBreakerMapPool": "", "maps.template": "", "maps.template.none": "", "maps.template.presets": "", @@ -214,14 +213,10 @@ "maps.template.preset.ANARCHY": "", "maps.template.preset.ALL": "", "maps.template.preset.onlyMode": "", - "maps.validation.PICKING": "", "maps.validation.STAGE_REPEAT_IN_SAME_MODE": "", - "maps.validation.NOT_ONE_MAP_PER_MODE": "", - "maps.validation.MAP_REPEATED": "", - "maps.validation.MODE_REPEATED": "", "maps.validation.TOO_MUCH_STAGE_REPEAT": "", - "maps.validation.INCLUDES_BANNED": "", - "maps.validation.INCLUDES_TIEBREAKER": "", + "maps.validation.NOT_IN_POOL": "", + "maps.banned": "", "maps.validation.VALID": "", "results": "Uitslagen", "forms.name": "Naam", diff --git a/locales/nl/forms.json b/locales/nl/forms.json index 3d61e19e8..ea1856b4f 100644 --- a/locales/nl/forms.json +++ b/locales/nl/forms.json @@ -146,6 +146,9 @@ "errors.staffCannotBeAuthor": "", "errors.divBothOrNeither": "", "errors.invalidMapPool": "", + "errors.teamPick.noModes": "", + "errors.teamPick.poolTooSmall": "", + "errors.teamPick.countOutOfRange": "", "errors.scrimTournamentRequired": "", "errors.tournamentMustBeSelected": "", "errors.visibilityMustBeDifferent": "", @@ -349,6 +352,16 @@ "labels.draft": "", "labels.requireSendouQ": "", "labels.mapPickingStyle": "", + "labels.teamPickModes": "", + "labels.teamPickCounts": "", + "bottomTexts.teamPickCounts": "", + "labels.teamPickPool": "", + "options.mapPickingStyle.TO": "", + "options.mapPickingStyle.AUTO": "", + "options.teamPickPool.SENDOUQ": "", + "options.teamPickPool.ALL": "", + "options.teamPickPool.CUSTOM": "", + "bottomTexts.teamPickReset": "", "bottomTexts.regClosesAt": "", "bottomTexts.maxTeamSize": "", "bottomTexts.avatarValidation": "", @@ -361,12 +374,6 @@ "bottomTexts.test": "", "bottomTexts.draftInfo": "", "bottomTexts.requireSendouQ": "", - "options.toToolsMode.ALL": "", - "options.toToolsMode.SZ": "", - "options.toToolsMode.TC": "", - "options.toToolsMode.RM": "", - "options.toToolsMode.CB": "", - "options.toToolsMode.TO": "", "options.regClosesAt.0": "", "options.regClosesAt.5min": "", "options.regClosesAt.10min": "", @@ -382,7 +389,6 @@ "options.regClosesAt.24h": "", "options.regClosesAt.48h": "", "options.regClosesAt.72h": "", - "errors.allModePool": "", "errors.bracketUrlRequired": "", "errors.bracketProgressionRequired": "", "labels.brackets": "", diff --git a/locales/nl/tournament.json b/locales/nl/tournament.json index ee9791df1..27a82041a 100644 --- a/locales/nl/tournament.json +++ b/locales/nl/tournament.json @@ -68,7 +68,16 @@ "pre.googleForm.header": "", "pre.googleForm.link": "", "pre.googleForm.footer": "", - "rules.tiebreakerMapPool": "", + "rules.teamPick.picks": "", + "rules.teamPick.pool.SENDOUQ": "", + "rules.teamPick.pool.ALL": "", + "rules.teamPick.pool.CUSTOM": "", + "rules.teamPick.neutral": "", + "mapList.rerollModeOrder": "", + "mapList.rerollAllMaps": "", + "mapList.teamsPick": "", + "mapList.customFlow": "", + "rules.teamPick.stageRepeat": "", "team.label": "", "team.teamPage": "", "team.vs": "", @@ -80,7 +89,7 @@ "pickInfo.team.counterpick": "", "pickInfo.pickedBy": "", "pickInfo.counterpickedBy": "", - "pickInfo.tiebreaker": "", + "pickInfo.random": "", "pickInfo.both": "", "pickInfo.default": "", "pickInfo.counterpick": "", diff --git a/locales/pl/calendar.json b/locales/pl/calendar.json index 6576f457f..8b20e0b9f 100644 --- a/locales/pl/calendar.json +++ b/locales/pl/calendar.json @@ -22,6 +22,8 @@ "forms.trophy": "", "forms.trophy.placeholder": "", "forms.mapPool": "Pula map", + "forms.teamPick.poolShortfall": "", + "forms.teamPick.poolOk": "", "forms.reportResultsHeader": "Zgłaszanie wyników dla {{eventName}}", "forms.reportResultsInfo": "Możesz wybrać ile zgłosić wyników. Mogą to być wyniki drużyny zwyciężonej, Top 3 lub ile chcesz.", "forms.team.placing": "Miejsce", diff --git a/locales/pl/common.json b/locales/pl/common.json index 791840b25..7910e9481 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -206,7 +206,6 @@ "maps.halfSz": "50% SZ", "maps.mapPool": "Pula map", "maps.tournamentMaplist": "Stwórz liste map do turnieju (maps.iplabs.ink)", - "maps.tieBreakerMapPool": "Tie breaker map pool", "maps.template": "Szablon", "maps.template.none": "Brak", "maps.template.presets": "Presety", @@ -214,14 +213,10 @@ "maps.template.preset.ANARCHY": "Tryby Anarchy", "maps.template.preset.ALL": "Wszystkie tryby", "maps.template.preset.onlyMode": "Tylko {{modeName}}", - "maps.validation.PICKING": "Wybierz jedną mape na tryb", "maps.validation.STAGE_REPEAT_IN_SAME_MODE": "", - "maps.validation.NOT_ONE_MAP_PER_MODE": "Wybierz tylko jedną mape na tryb", - "maps.validation.MAP_REPEATED": "Nie można wybrać tą samą mape więcej niż raz", - "maps.validation.MODE_REPEATED": "Nie można wybrać ten sam tryb więcej niż raz", "maps.validation.TOO_MUCH_STAGE_REPEAT": "Nie można wybrać mapy więcej niż {{maxStageRepeat}} razy", - "maps.validation.INCLUDES_BANNED": "", - "maps.validation.INCLUDES_TIEBREAKER": "", + "maps.validation.NOT_IN_POOL": "", + "maps.banned": "", "maps.validation.VALID": "Pula map jest okej!", "results": "Wyniki", "forms.name": "Imię", diff --git a/locales/pl/forms.json b/locales/pl/forms.json index aeb87d2e2..e1b8170a1 100644 --- a/locales/pl/forms.json +++ b/locales/pl/forms.json @@ -146,6 +146,9 @@ "errors.staffCannotBeAuthor": "", "errors.divBothOrNeither": "", "errors.invalidMapPool": "", + "errors.teamPick.noModes": "", + "errors.teamPick.poolTooSmall": "", + "errors.teamPick.countOutOfRange": "", "errors.scrimTournamentRequired": "", "errors.tournamentMustBeSelected": "", "errors.visibilityMustBeDifferent": "", @@ -349,6 +352,16 @@ "labels.draft": "", "labels.requireSendouQ": "", "labels.mapPickingStyle": "", + "labels.teamPickModes": "", + "labels.teamPickCounts": "", + "bottomTexts.teamPickCounts": "", + "labels.teamPickPool": "", + "options.mapPickingStyle.TO": "", + "options.mapPickingStyle.AUTO": "", + "options.teamPickPool.SENDOUQ": "", + "options.teamPickPool.ALL": "", + "options.teamPickPool.CUSTOM": "", + "bottomTexts.teamPickReset": "", "bottomTexts.regClosesAt": "", "bottomTexts.maxTeamSize": "", "bottomTexts.avatarValidation": "", @@ -361,12 +374,6 @@ "bottomTexts.test": "", "bottomTexts.draftInfo": "", "bottomTexts.requireSendouQ": "", - "options.toToolsMode.ALL": "", - "options.toToolsMode.SZ": "", - "options.toToolsMode.TC": "", - "options.toToolsMode.RM": "", - "options.toToolsMode.CB": "", - "options.toToolsMode.TO": "", "options.regClosesAt.0": "", "options.regClosesAt.5min": "", "options.regClosesAt.10min": "", @@ -382,7 +389,6 @@ "options.regClosesAt.24h": "", "options.regClosesAt.48h": "", "options.regClosesAt.72h": "", - "errors.allModePool": "", "errors.bracketUrlRequired": "", "errors.bracketProgressionRequired": "", "labels.brackets": "", diff --git a/locales/pl/tournament.json b/locales/pl/tournament.json index f9e90bf1c..ab5713160 100644 --- a/locales/pl/tournament.json +++ b/locales/pl/tournament.json @@ -68,7 +68,16 @@ "pre.googleForm.header": "", "pre.googleForm.link": "", "pre.googleForm.footer": "", - "rules.tiebreakerMapPool": "", + "rules.teamPick.picks": "", + "rules.teamPick.pool.SENDOUQ": "", + "rules.teamPick.pool.ALL": "", + "rules.teamPick.pool.CUSTOM": "", + "rules.teamPick.neutral": "", + "mapList.rerollModeOrder": "", + "mapList.rerollAllMaps": "", + "mapList.teamsPick": "", + "mapList.customFlow": "", + "rules.teamPick.stageRepeat": "", "team.label": "Drużyna", "team.teamPage": "", "team.vs": "", @@ -80,7 +89,7 @@ "pickInfo.team.counterpick": "", "pickInfo.pickedBy": "", "pickInfo.counterpickedBy": "", - "pickInfo.tiebreaker": "Tiebreaker", + "pickInfo.random": "", "pickInfo.both": "Oba wybrane", "pickInfo.default": "", "pickInfo.counterpick": "", diff --git a/locales/pt-BR/calendar.json b/locales/pt-BR/calendar.json index 6a660aeb2..2d8919ab7 100644 --- a/locales/pt-BR/calendar.json +++ b/locales/pt-BR/calendar.json @@ -20,6 +20,8 @@ "forms.trophy": "", "forms.trophy.placeholder": "", "forms.mapPool": "Conjunto de mapas", + "forms.teamPick.poolShortfall": "", + "forms.teamPick.poolOk": "", "forms.reportResultsHeader": "Declarando resultados para o(a) {{eventName}}", "forms.reportResultsInfo": "Você decide quantos resultados declarar. Pode ser apenas o time vencedor, Top 3 ou qualquer outro tipo de placar que você quiser.", "forms.team.placing": "Placar", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index ba550e484..1cc7151a9 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -206,7 +206,6 @@ "maps.halfSz": "50% Zones", "maps.mapPool": "Seleção de mapas", "maps.tournamentMaplist": "Criar seleção de mapas para torneio (maps.iplabs.ink)", - "maps.tieBreakerMapPool": "Seleção de mapas para desempate", "maps.template": "Modelo", "maps.template.none": "Nenhum", "maps.template.presets": "Pré-definições", @@ -214,14 +213,10 @@ "maps.template.preset.ANARCHY": "Modos do Anarchy", "maps.template.preset.ALL": "Todos os Modos", "maps.template.preset.onlyMode": "Apenas {{modeName}}", - "maps.validation.PICKING": "Escolher um mapa (fase, tela, etc.) por modo", "maps.validation.STAGE_REPEAT_IN_SAME_MODE": "Não é possível escolher o mesmo mapa duas vezes no mesmo modo.", - "maps.validation.NOT_ONE_MAP_PER_MODE": "Escolher apenas um mapa por modo", - "maps.validation.MAP_REPEATED": "Não é possível escolher o mesmo mapa mais de uma vez", - "maps.validation.MODE_REPEATED": "Não é possível escolher o mesmo modo mais de uma vez", "maps.validation.TOO_MUCH_STAGE_REPEAT": "Não é possível escolher o mapa mais que {{maxStageRepeat}} vezes", - "maps.validation.INCLUDES_BANNED": "Não é possível escolher mapas banidos", - "maps.validation.INCLUDES_TIEBREAKER": "Não é possível escolher o mapa de desempate", + "maps.validation.NOT_IN_POOL": "", + "maps.banned": "", "maps.validation.VALID": "Seleção de mapas OK!", "results": "Resultados", "forms.name": "Nome", diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json index 91d6fca19..5ce4ee1fb 100644 --- a/locales/pt-BR/forms.json +++ b/locales/pt-BR/forms.json @@ -146,6 +146,9 @@ "errors.staffCannotBeAuthor": "", "errors.divBothOrNeither": "", "errors.invalidMapPool": "", + "errors.teamPick.noModes": "", + "errors.teamPick.poolTooSmall": "", + "errors.teamPick.countOutOfRange": "", "errors.scrimTournamentRequired": "", "errors.tournamentMustBeSelected": "", "errors.visibilityMustBeDifferent": "", @@ -349,6 +352,16 @@ "labels.draft": "", "labels.requireSendouQ": "", "labels.mapPickingStyle": "", + "labels.teamPickModes": "", + "labels.teamPickCounts": "", + "bottomTexts.teamPickCounts": "", + "labels.teamPickPool": "", + "options.mapPickingStyle.TO": "", + "options.mapPickingStyle.AUTO": "", + "options.teamPickPool.SENDOUQ": "", + "options.teamPickPool.ALL": "", + "options.teamPickPool.CUSTOM": "", + "bottomTexts.teamPickReset": "", "bottomTexts.regClosesAt": "", "bottomTexts.maxTeamSize": "", "bottomTexts.avatarValidation": "", @@ -361,12 +374,6 @@ "bottomTexts.test": "", "bottomTexts.draftInfo": "", "bottomTexts.requireSendouQ": "", - "options.toToolsMode.ALL": "", - "options.toToolsMode.SZ": "", - "options.toToolsMode.TC": "", - "options.toToolsMode.RM": "", - "options.toToolsMode.CB": "", - "options.toToolsMode.TO": "", "options.regClosesAt.0": "", "options.regClosesAt.5min": "", "options.regClosesAt.10min": "", @@ -382,7 +389,6 @@ "options.regClosesAt.24h": "", "options.regClosesAt.48h": "", "options.regClosesAt.72h": "", - "errors.allModePool": "", "errors.bracketUrlRequired": "", "errors.bracketProgressionRequired": "", "labels.brackets": "", diff --git a/locales/pt-BR/tournament.json b/locales/pt-BR/tournament.json index 065298713..3c51a9750 100644 --- a/locales/pt-BR/tournament.json +++ b/locales/pt-BR/tournament.json @@ -68,7 +68,16 @@ "pre.googleForm.header": "", "pre.googleForm.link": "", "pre.googleForm.footer": "", - "rules.tiebreakerMapPool": "", + "rules.teamPick.picks": "", + "rules.teamPick.pool.SENDOUQ": "", + "rules.teamPick.pool.ALL": "", + "rules.teamPick.pool.CUSTOM": "", + "rules.teamPick.neutral": "", + "mapList.rerollModeOrder": "", + "mapList.rerollAllMaps": "", + "mapList.teamsPick": "", + "mapList.customFlow": "", + "rules.teamPick.stageRepeat": "", "team.label": "Time", "team.teamPage": "", "team.vs": "", @@ -80,7 +89,7 @@ "pickInfo.team.counterpick": "", "pickInfo.pickedBy": "", "pickInfo.counterpickedBy": "", - "pickInfo.tiebreaker": "Desempate", + "pickInfo.random": "", "pickInfo.both": "Ambos escolheram", "pickInfo.default": "", "pickInfo.counterpick": "Counterpick", diff --git a/locales/ru/calendar.json b/locales/ru/calendar.json index 351290d94..a0d8a859b 100644 --- a/locales/ru/calendar.json +++ b/locales/ru/calendar.json @@ -22,6 +22,8 @@ "forms.trophy": "", "forms.trophy.placeholder": "", "forms.mapPool": "Список карт", + "forms.teamPick.poolShortfall": "", + "forms.teamPick.poolOk": "", "forms.reportResultsHeader": "Указать результаты для {{eventName}}", "forms.reportResultsInfo": "Вы можете указать столько результатов, сколько сочтёте нужным. Это может быть команда победителей, топ-3 или как вы сами захотите.", "forms.team.placing": "Место", diff --git a/locales/ru/common.json b/locales/ru/common.json index 367b78d60..8ab4916a2 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -206,7 +206,6 @@ "maps.halfSz": "50% Зон", "maps.mapPool": "Пул карт", "maps.tournamentMaplist": "Создать список карт для турнира (maps.iplabs.ink)", - "maps.tieBreakerMapPool": "Список карт для тай-брейка", "maps.template": "Шаблон", "maps.template.none": "Отсутствует", "maps.template.presets": "Пресеты", @@ -214,14 +213,10 @@ "maps.template.preset.ANARCHY": "Режимы стихийных боёв", "maps.template.preset.ALL": "Все режимы", "maps.template.preset.onlyMode": "Только {{modeName}}", - "maps.validation.PICKING": "Выберите одну карту для каждого режима", "maps.validation.STAGE_REPEAT_IN_SAME_MODE": "Карта не может повторяться на одном режиме", - "maps.validation.NOT_ONE_MAP_PER_MODE": "Выберите только одну карту для режима", - "maps.validation.MAP_REPEATED": "Нельзя выбрать ту же самую карту дважды", - "maps.validation.MODE_REPEATED": "Нельзя выбрать тот же самый режим дважды", "maps.validation.TOO_MUCH_STAGE_REPEAT": "Можно выбрать карту не больше чем {{maxStageRepeat}} раз(а)", - "maps.validation.INCLUDES_BANNED": "Нельзя выбрать запрещённую арену", - "maps.validation.INCLUDES_TIEBREAKER": "Нельзя выбрать арену-тайбрейкер", + "maps.validation.NOT_IN_POOL": "", + "maps.banned": "", "maps.validation.VALID": "Список карт в порядке!", "results": "Результаты", "forms.name": "Название", diff --git a/locales/ru/forms.json b/locales/ru/forms.json index fe743f692..3f78af695 100644 --- a/locales/ru/forms.json +++ b/locales/ru/forms.json @@ -146,6 +146,9 @@ "errors.staffCannotBeAuthor": "", "errors.divBothOrNeither": "", "errors.invalidMapPool": "", + "errors.teamPick.noModes": "", + "errors.teamPick.poolTooSmall": "", + "errors.teamPick.countOutOfRange": "", "errors.scrimTournamentRequired": "", "errors.tournamentMustBeSelected": "", "errors.visibilityMustBeDifferent": "", @@ -349,6 +352,16 @@ "labels.draft": "", "labels.requireSendouQ": "", "labels.mapPickingStyle": "", + "labels.teamPickModes": "", + "labels.teamPickCounts": "", + "bottomTexts.teamPickCounts": "", + "labels.teamPickPool": "", + "options.mapPickingStyle.TO": "", + "options.mapPickingStyle.AUTO": "", + "options.teamPickPool.SENDOUQ": "", + "options.teamPickPool.ALL": "", + "options.teamPickPool.CUSTOM": "", + "bottomTexts.teamPickReset": "", "bottomTexts.regClosesAt": "", "bottomTexts.maxTeamSize": "", "bottomTexts.avatarValidation": "", @@ -361,12 +374,6 @@ "bottomTexts.test": "", "bottomTexts.draftInfo": "", "bottomTexts.requireSendouQ": "", - "options.toToolsMode.ALL": "", - "options.toToolsMode.SZ": "", - "options.toToolsMode.TC": "", - "options.toToolsMode.RM": "", - "options.toToolsMode.CB": "", - "options.toToolsMode.TO": "", "options.regClosesAt.0": "", "options.regClosesAt.5min": "", "options.regClosesAt.10min": "", @@ -382,7 +389,6 @@ "options.regClosesAt.24h": "", "options.regClosesAt.48h": "", "options.regClosesAt.72h": "", - "errors.allModePool": "", "errors.bracketUrlRequired": "", "errors.bracketProgressionRequired": "", "labels.brackets": "", diff --git a/locales/ru/tournament.json b/locales/ru/tournament.json index 3ff549dc5..a0f7fc3b0 100644 --- a/locales/ru/tournament.json +++ b/locales/ru/tournament.json @@ -68,7 +68,16 @@ "pre.googleForm.header": "", "pre.googleForm.link": "", "pre.googleForm.footer": "", - "rules.tiebreakerMapPool": "", + "rules.teamPick.picks": "", + "rules.teamPick.pool.SENDOUQ": "", + "rules.teamPick.pool.ALL": "", + "rules.teamPick.pool.CUSTOM": "", + "rules.teamPick.neutral": "", + "mapList.rerollModeOrder": "", + "mapList.rerollAllMaps": "", + "mapList.teamsPick": "", + "mapList.customFlow": "", + "rules.teamPick.stageRepeat": "", "team.label": "Команда", "team.teamPage": "", "team.vs": "", @@ -80,7 +89,7 @@ "pickInfo.team.counterpick": "", "pickInfo.pickedBy": "", "pickInfo.counterpickedBy": "", - "pickInfo.tiebreaker": "Тай-брейк", + "pickInfo.random": "", "pickInfo.both": "Выбрано обеими", "pickInfo.default": "", "pickInfo.counterpick": "Контрвыбор", diff --git a/locales/zh/calendar.json b/locales/zh/calendar.json index fe351b8db..c150f4fa3 100644 --- a/locales/zh/calendar.json +++ b/locales/zh/calendar.json @@ -16,6 +16,8 @@ "forms.trophy": "", "forms.trophy.placeholder": "", "forms.mapPool": "场地池", + "forms.teamPick.poolShortfall": "", + "forms.teamPick.poolOk": "", "forms.reportResultsHeader": "汇报 {{eventName}} 的结果", "forms.reportResultsInfo": "您可以自定义要显示的赛事结果。可以选择仅冠军队伍、前三名或其他结果。", "forms.team.placing": "排名", diff --git a/locales/zh/common.json b/locales/zh/common.json index b0992f6a2..24fc126ad 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -206,7 +206,6 @@ "maps.halfSz": "真格区域占 50%", "maps.mapPool": "场地池", "maps.tournamentMaplist": "创建赛事场地列表 (maps.iplabs.ink)", - "maps.tieBreakerMapPool": "决胜局场地池", "maps.template": "模版", "maps.template.none": "无", "maps.template.presets": "预设", @@ -214,14 +213,10 @@ "maps.template.preset.ANARCHY": "蛮颓比赛", "maps.template.preset.ALL": "全部模式", "maps.template.preset.onlyMode": "仅限 {{modeName}}", - "maps.validation.PICKING": "为每个模式选择一个场地", "maps.validation.STAGE_REPEAT_IN_SAME_MODE": "无法在相同模式下选择重复的场地", - "maps.validation.NOT_ONE_MAP_PER_MODE": "每个模式只能选择一个场地", - "maps.validation.MAP_REPEATED": "每个场地只能选择一次", - "maps.validation.MODE_REPEATED": "每个模式只能选择一次", "maps.validation.TOO_MUCH_STAGE_REPEAT": "每个场地只能选择 {{maxStageRepeat}} 次", - "maps.validation.INCLUDES_BANNED": "无法选择被禁用的场地", - "maps.validation.INCLUDES_TIEBREAKER": "无法选择决胜局场地", + "maps.validation.NOT_IN_POOL": "", + "maps.banned": "", "maps.validation.VALID": "场地池已完成!", "results": "结果", "forms.name": "名称", diff --git a/locales/zh/forms.json b/locales/zh/forms.json index 2c3107697..f9c35d7c9 100644 --- a/locales/zh/forms.json +++ b/locales/zh/forms.json @@ -146,6 +146,9 @@ "errors.staffCannotBeAuthor": "该赛事的创建者已经是组织者了", "errors.divBothOrNeither": "最低级别和最高级别必须同时设置或同时不设置", "errors.invalidMapPool": "无效的场地池", + "errors.teamPick.noModes": "", + "errors.teamPick.poolTooSmall": "", + "errors.teamPick.countOutOfRange": "", "errors.scrimTournamentRequired": "请选择一项赛事", "errors.tournamentMustBeSelected": "当场地池来源为赛事时,必须选择一项赛事", "errors.visibilityMustBeDifferent": "未找到状态下的可见范围必须与基础可见范围不同", @@ -349,6 +352,16 @@ "labels.draft": "草稿", "labels.requireSendouQ": "要求 SendouQ 参与度", "labels.mapPickingStyle": "场地选择方式", + "labels.teamPickModes": "", + "labels.teamPickCounts": "", + "bottomTexts.teamPickCounts": "", + "labels.teamPickPool": "", + "options.mapPickingStyle.TO": "", + "options.mapPickingStyle.AUTO": "", + "options.teamPickPool.SENDOUQ": "", + "options.teamPickPool.ALL": "", + "options.teamPickPool.CUSTOM": "", + "bottomTexts.teamPickReset": "", "bottomTexts.regClosesAt": "所有时间均相对于已发布的赛事开始时间。例如“30 分钟”意味着“赛事开始前 30 分钟”。报名截止后,仅限赛事主办方可以修改队伍名单。", "bottomTexts.maxTeamSize": "每支队伍允许报名的最大选手人数。此限制不适用于赛事主办方。默认值为 6 人。", "bottomTexts.avatarValidation": "请注意,非赞助者上传头像后需要经过审核才会显示。", @@ -361,12 +374,6 @@ "bottomTexts.test": "测试赛事不会显示在日历上,不会向玩家发送通知,也不会显示在玩家的个人资料或比赛结果中。", "bottomTexts.draftInfo": "处于草稿状态的赛事会被隐藏,仅对举办者可见。在启动任何对战表之前,必须先关闭草稿状态(关闭此开关)以公开赛事。", "bottomTexts.requireSendouQ": "玩家必须在本赛季进行过足够场次的 SendouQ 对局才能报名", - "options.toToolsMode.ALL": "由队伍预选 - 所有模式", - "options.toToolsMode.SZ": "由队伍预选 - 仅限真格区域", - "options.toToolsMode.TC": "由队伍预选 - 仅限真格塔楼", - "options.toToolsMode.RM": "由队伍预选 - 仅限真格鱼虎对战", - "options.toToolsMode.CB": "由队伍预选 - 仅限真格蛤蜊", - "options.toToolsMode.TO": "由赛事主办方指定", "options.regClosesAt.0": "赛事开始时", "options.regClosesAt.5min": "5 分钟", "options.regClosesAt.10min": "10 分钟", @@ -382,7 +389,6 @@ "options.regClosesAt.24h": "24 小时", "options.regClosesAt.48h": "48 小时", "options.regClosesAt.72h": "72 小时", - "errors.allModePool": "如果使用“由队伍预选 - 所有模式”,场地池必须包含可用于每个蛮颓比赛模式的场地", "errors.bracketUrlRequired": "必须填写对战表 URL", "errors.bracketProgressionRequired": "必须为赛事设置对战表晋级规则", "labels.brackets": "", diff --git a/locales/zh/tournament.json b/locales/zh/tournament.json index face045a7..9a9f77e7b 100644 --- a/locales/zh/tournament.json +++ b/locales/zh/tournament.json @@ -68,7 +68,16 @@ "pre.googleForm.header": "", "pre.googleForm.link": "", "pre.googleForm.footer": "", - "rules.tiebreakerMapPool": "", + "rules.teamPick.picks": "", + "rules.teamPick.pool.SENDOUQ": "", + "rules.teamPick.pool.ALL": "", + "rules.teamPick.pool.CUSTOM": "", + "rules.teamPick.neutral": "", + "mapList.rerollModeOrder": "", + "mapList.rerollAllMaps": "", + "mapList.teamsPick": "", + "mapList.customFlow": "", + "rules.teamPick.stageRepeat": "", "team.label": "队伍", "team.teamPage": "", "team.vs": "", @@ -80,7 +89,7 @@ "pickInfo.team.counterpick": "{{team}} 进行了反选", "pickInfo.pickedBy": "由 {{teamName}} 选择", "pickInfo.counterpickedBy": "由 {{teamName}} 反选", - "pickInfo.tiebreaker": "决胜局场地", + "pickInfo.random": "", "pickInfo.both": "双方均选择", "pickInfo.default": "社区推荐", "pickInfo.counterpick": "反选", diff --git a/migrations/20260916090000-team-picked-map-lists.ts b/migrations/20260916090000-team-picked-map-lists.ts new file mode 100644 index 000000000..314395340 --- /dev/null +++ b/migrations/20260916090000-team-picked-map-lists.ts @@ -0,0 +1,57 @@ +import { type Kysely, sql } from "kysely"; + +/** + * Team picked map lists overhaul: the per-mode "AUTO_*" map picking styles collapse into "AUTO" + * with the modes and picks per mode in `Tournament.settings.teamPick`. The organizer set + * tiebreaker map pool is gone, the neutral map is now a map both teams picked or a random + * pool map neither picked. + */ +export async function up(db: Kysely): Promise { + await db.transaction().execute(async (trx) => { + await sql` + update "Tournament" + set "settings" = json_set( + "settings", + '$.teamPick', + json('{"modes":[{"mode":"SZ","count":2},{"mode":"TC","count":2},{"mode":"RM","count":2},{"mode":"CB","count":2}],"pool":"SENDOUQ"}') + ) + where "mapPickingStyle" = 'AUTO_ALL' + `.execute(trx); + + for (const mode of ["SZ", "TC", "RM", "CB"]) { + await sql` + update "Tournament" + set "settings" = json_set( + "settings", + '$.teamPick', + json(${`{"modes":[{"mode":"${mode}","count":6}],"pool":"SENDOUQ"}`}) + ) + where "mapPickingStyle" = ${`AUTO_${mode}`} + `.execute(trx); + } + + await sql` + update "Tournament" + set "mapPickingStyle" = 'AUTO' + where "mapPickingStyle" like 'AUTO_%' + `.execute(trx); + + await sql` + delete from "MapPoolMap" + where "tieBreakerCalendarEventId" is not null + `.execute(trx); + await trx.schema + .dropIndex("map_pool_map_tie_breaker_calendar_event_id") + .execute(); + await trx.schema + .alterTable("MapPoolMap") + .dropColumn("tieBreakerCalendarEventId") + .execute(); + + await sql` + update "TournamentMatchGameResult" + set "source" = 'RANDOM' + where "source" = 'TIEBREAKER' + `.execute(trx); + }); +} diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts index a4b063c24..436004fcc 100644 --- a/scripts/benchmark-db/cases.ts +++ b/scripts/benchmark-db/cases.ts @@ -274,7 +274,6 @@ export function buildCases(fx: Fixtures): { add("CalendarRepository.findById", fx.heavyCalendarEventId, (eventId) => CalendarRepository.findById(eventId, { includeMapPool: true, - includeTieBreakerMapPool: true, includeBadgePrizes: true, }), );