diff --git a/AGENTS.md b/AGENTS.md index 05430e50e..93a155ec0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,11 @@ ## General -- only rarely use comments, prefer descriptive variable and function names (leave existing comments as is) +- only rarely use comments, prefer descriptive variable and function names (leave existing comments as is). - if you encounter an existing TODO comment assume it is there for a reason and do not remove it - task is not considered completely until `pnpm run checks` passes - normal file structure has constants at the top immediately followed by the main function body of the file. Helpers are used to structure the code and they are at the bottom of the file (main implementation first, at the top of the file) - note: any formatting issue (such as tabs vs. spaces) can be resolved by running the `pnpm run biome:fix` command +- typical way to structure pure logic is into Modules divided by logical domains which are imported with the "* as Module" import and then used like so "Module.foo()". These functions always need JSDoc. ## Commands @@ -28,7 +29,7 @@ - prefer functional components over class components - prefer using hooks over class lifecycle methods -- do not use `useMemo`, `useCallback` or `useReducer` at all +- do not use `useMemo`, `useCallback` unless it is to stabilize a `useEffect` dependency array value - state management is done via plain `useState` and React Context API - avoid using `useEffect` - split bigger components into smaller ones @@ -55,6 +56,7 @@ - database code should only be written in Repository files - down migrations are not needed, only up migrations - every database id is of type number +- if we are working on a branch by default we should add to the migration this branch added instead of creating a brand new one - `/app/db/tables.ts` contains all tables and columns available - `db.sqlite3` is development database - `db-test.sqlite3` is the unit test database (should be blank sans migrations ran) @@ -65,11 +67,6 @@ - library used for unit testing is Vitest - Vitest browser mode can be used to write tests for components -## Testing in Chrome - -- some pages need authentication, you should impersonate "Sendou" user which can be done on the /admin page -- port can be checked from the `.env` file, you can assume dev server is already running - ## i18n - by default everything should be translated via i18next diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 8f39dd692..f093aa742 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -241,6 +241,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [ liveStreams, splatoonRotations, variation === "FINALIZED_BRACKET" ? finalizedBracket : undefined, + variation === "AB_RR" ? abDivisionsTournament : undefined, ]; export async function seed(variation?: SeedVariation | null) { @@ -510,6 +511,106 @@ function finalizedBracket() { } } +const AB_RR_TOURNAMENT_ID = 8; +const AB_RR_EVENT_ID = 208; +const AB_RR_TEAM_ID_OFFSET = 700; +const AB_RR_TEAM_COUNT = 12; + +function abDivisionsTournament() { + sql + .prepare( + `insert into "Tournament" ("id", "mapPickingStyle", "settings") + values ($id, $mapPickingStyle, $settings)`, + ) + .run({ + id: AB_RR_TOURNAMENT_ID, + mapPickingStyle: "AUTO_ALL", + settings: JSON.stringify({ + bracketProgression: [ + { + type: "round_robin", + name: "Groups stage", + requiresCheckIn: false, + settings: { + hasAbDivisions: true, + teamsPerGroup: AB_RR_TEAM_COUNT, + }, + }, + ], + }), + }); + + sql + .prepare( + `insert into "CalendarEvent" ("id", "name", "description", "discordInviteCode", "bracketUrl", "authorId", "tournamentId") + values ($id, $name, $description, $discordInviteCode, $bracketUrl, $authorId, $tournamentId)`, + ) + .run({ + id: AB_RR_EVENT_ID, + name: "A/B Divisions Cup", + description: "Bipartite round robin tournament for testing", + discordInviteCode: "abrr", + bracketUrl: "https://example.com", + authorId: ADMIN_ID, + tournamentId: AB_RR_TOURNAMENT_ID, + }); + + sql + .prepare( + `insert into "CalendarEventDate" ("eventId", "startTime") + values ($eventId, $startTime)`, + ) + .run({ + eventId: AB_RR_EVENT_ID, + startTime: dateToDatabaseTimestamp(new Date(Date.now() - 1000 * 60 * 30)), + }); + + const userIds = userIdsInAscendingOrderById(); + const now = dateToDatabaseTimestamp(new Date()); + + for (let i = 0; i < AB_RR_TEAM_COUNT; i++) { + const teamId = AB_RR_TEAM_ID_OFFSET + i + 1; + + sql + .prepare( + `insert into "TournamentTeam" ("id", "name", "createdAt", "tournamentId", "inviteCode", "seed") + values ($id, $name, $createdAt, $tournamentId, $inviteCode, $seed)`, + ) + .run({ + id: teamId, + name: `AB Team ${i + 1}`, + createdAt: now, + tournamentId: AB_RR_TOURNAMENT_ID, + inviteCode: shortNanoid(), + seed: i + 1, + }); + + sql + .prepare( + `insert into "TournamentTeamCheckIn" ("tournamentTeamId", "checkedInAt") + values ($tournamentTeamId, $checkedInAt)`, + ) + .run({ + tournamentTeamId: teamId, + checkedInAt: now, + }); + + for (let j = 0; j < 4; j++) { + sql + .prepare( + `insert into "TournamentTeamMember" ("tournamentTeamId", "userId", "createdAt", "role") + values ($tournamentTeamId, $userId, $createdAt, $role)`, + ) + .run({ + tournamentTeamId: teamId, + userId: userIds.shift()!, + createdAt: now, + role: j === 0 ? "OWNER" : "REGULAR", + }); + } + } +} + function wipeDB() { const tablesToDelete = [ "ScrimPost", diff --git a/app/db/tables.ts b/app/db/tables.ts index bae6bcbfc..fa125b60d 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -744,6 +744,8 @@ export interface TournamentStageSettings { thirdPlaceMatch?: boolean; // RR teamsPerGroup?: number; + /** (RR only) When true, teams are split into A and B divisions and matches only pair A-vs-B. Only valid on starting brackets. */ + hasAbDivisions?: boolean; // SWISS groupCount?: number; // SWISS @@ -814,6 +816,8 @@ export interface TournamentTeam { isPlaceholder: Generated; lfgNote: string | null; chatCode: Generated; + /** A/B division assignment for bipartite round robin brackets. `0` = A, `1` = B, `null` = unassigned. */ + abDivision: number | null; } export interface TournamentTeamCheckIn { diff --git a/app/features/api-private/constants.ts b/app/features/api-private/constants.ts index d4611d366..80c54f859 100644 --- a/app/features/api-private/constants.ts +++ b/app/features/api-private/constants.ts @@ -8,4 +8,5 @@ export const SEED_VARIATIONS = [ "NO_SQ_GROUPS", "TEAM_MAP_PREFS", "FINALIZED_BRACKET", + "AB_RR", ] as const; diff --git a/app/features/calendar/calendar-schemas.ts b/app/features/calendar/calendar-schemas.ts index 8252df7d0..3db80b10f 100644 --- a/app/features/calendar/calendar-schemas.ts +++ b/app/features/calendar/calendar-schemas.ts @@ -252,6 +252,7 @@ export const bracketProgressionSchema = z.preprocess( .object({ thirdPlaceMatch: z.boolean().optional(), teamsPerGroup: z.number().int().optional(), + hasAbDivisions: z.boolean().optional(), groupCount: z.number().int().optional(), roundCount: z.number().int().optional(), advanceThreshold: z.number().int().optional(), diff --git a/app/features/calendar/components/BracketProgressionSelector.tsx b/app/features/calendar/components/BracketProgressionSelector.tsx index 6d5e5224c..196dd2490 100644 --- a/app/features/calendar/components/BracketProgressionSelector.tsx +++ b/app/features/calendar/components/BracketProgressionSelector.tsx @@ -318,10 +318,14 @@ function TournamentFormatBracketSelector({ id="teamsPerGroup" disabled={bracket.disabled} > - - - - + {(bracket.settings.hasAbDivisions + ? TOURNAMENT.RR_AB_DIVISIONS_TEAMS_PER_GROUP_OPTIONS + : TOURNAMENT.RR_TEAMS_PER_GROUP_OPTIONS + ).map((n) => ( + + ))} Participants are distributed equally, so groups may have fewer @@ -330,6 +334,44 @@ function TournamentFormatBracketSelector({ ) : null} + {bracket.type === "round_robin" && !bracket.sources ? ( +
+ + { + const currentTeamsPerGroup = + bracket.settings.teamsPerGroup ?? + TOURNAMENT.RR_DEFAULT_TEAM_COUNT_PER_GROUP; + + const maxWithoutAb = Math.max( + ...TOURNAMENT.RR_TEAMS_PER_GROUP_OPTIONS, + ); + + let nextTeamsPerGroup = currentTeamsPerGroup; + if (isSelected && currentTeamsPerGroup % 2 !== 0) { + nextTeamsPerGroup = currentTeamsPerGroup + 1; + } else if (!isSelected && currentTeamsPerGroup > maxWithoutAb) { + nextTeamsPerGroup = maxWithoutAb; + } + + updateBracket({ + settings: { + ...bracket.settings, + hasAbDivisions: isSelected, + teamsPerGroup: nextTeamsPerGroup, + }, + }); + }} + isDisabled={bracket.disabled} + /> + + Teams split into A and B pools; every A plays every B once + +
+ ) : null} + {bracket.type === "swiss" ? (
diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts index 0e0bc1b13..fc5c039ab 100644 --- a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts +++ b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts @@ -23,6 +23,7 @@ import { import { assertUnreachable } from "~/utils/types"; import { idObject } from "~/utils/zod"; import type { PreparedMaps } from "../../../db/tables"; +import * as AbDivisions from "../core/AbDivisions"; import { getServerTournamentManager } from "../core/brackets-manager/manager.server"; import { roundMapsFromInput } from "../core/mapList.server"; import * as PreparedMapsUtils from "../core/PreparedMaps"; @@ -85,6 +86,11 @@ export const action: ActionFunction = async ({ params, request }) => { }) : data.maps; + const abDivisions = + bracket.type === "round_robin" && bracket.settings?.hasAbDivisions + ? abDivisionsForSeeding(seeding, tournament, groupCount) + : undefined; + errorToastIfFalsy( bracket.type === "round_robin" || bracket.type === "swiss" ? bracket.data.round.length / groupCount === maps.length @@ -112,6 +118,7 @@ export const action: ActionFunction = async ({ params, request }) => { ? seeding : fillWithNullTillPowerOfTwo(seeding), settings, + abDivisions, }); updateRoundMaps( @@ -358,6 +365,23 @@ function errorToastIfFalsyNoFollowUpBrackets(tournament: Tournament) { ); } +function abDivisionsForSeeding( + seeding: number[], + tournament: Tournament, + groupCount: number, +): (0 | 1)[] { + const abDivisionsBySeedOrder = seeding.map((teamId) => { + const team = tournament.teamById(teamId); + errorToastIfFalsy(team, "Team not found when building A/B divisions"); + return team.abDivision; + }); + + const result = AbDivisions.validate({ abDivisionsBySeedOrder, groupCount }); + errorToastIfErr(result); + + return result.value; +} + function adjustLinkedRounds({ maps, thirdPlaceMatchLinked, diff --git a/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx b/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx index 1a6873ef6..4af034d9b 100644 --- a/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx +++ b/app/features/tournament-bracket/components/Bracket/PlacementsTable.tsx @@ -8,7 +8,7 @@ import { logger } from "../../../../utils/logger"; import { tournamentTeamPage } from "../../../../utils/urls"; import { useUser } from "../../../auth/core/user"; import { TOURNAMENT } from "../../../tournament/tournament-constants"; -import type { Bracket } from "../../core/Bracket"; +import type { Bracket, Standing } from "../../core/Bracket"; import * as Progression from "../../core/Progression"; import * as Swiss from "../../core/Swiss"; import styles from "./bracket.module.css"; @@ -75,9 +75,8 @@ export function PlacementsTable({ return a.placement - b.placement; }); - const destinationBracket = (placement: number) => { + const destinationBracket = (standing: Standing, placement: number) => { if (bracket.type === "swiss" && bracket.settings?.advanceThreshold) { - const standing = standings[placement - 1]; const stats = standing.stats; invariant(stats); @@ -125,11 +124,83 @@ export function PlacementsTable({ ); })(); + if (bracket.settings?.hasAbDivisions) { + const aStandings = standings.filter((s) => s.team.abDivision === 0); + const bStandings = standings.filter((s) => s.team.abDivision === 1); + + if (aStandings.length === 0 && bStandings.length === 0) { + return null; + } + + return ( +
+ {aStandings.length > 0 ? ( + + ) : null} + {bStandings.length > 0 ? ( + + ) : null} +
+ ); + } + + if (standings.length === 0) { + return null; + } + + return ( + + ); +} + +function StandingsTable({ + bracket, + standings, + destinationBracket, + possibleDestinationBrackets, + canEditDestination, + allMatchesFinished, +}: { + bracket: Bracket; + standings: Standing[]; + destinationBracket: ( + standing: Standing, + placement: number, + ) => Bracket | undefined; + possibleDestinationBrackets: Bracket[]; + canEditDestination: boolean; + allMatchesFinished: boolean; +}) { let qualifiedRowRendered = false; let eliminatedRowRendered = false; return ( - +
@@ -179,7 +250,7 @@ export function PlacementsTable({ const team = bracket.tournament.teamById(s.team.id); - const dest = destinationBracket(i + 1); + const dest = destinationBracket(s, i + 1); const overridenDestination = bracket.tournament.ctx.bracketProgressionOverrides.find( diff --git a/app/features/tournament-bracket/core/AbDivisions.test.ts b/app/features/tournament-bracket/core/AbDivisions.test.ts new file mode 100644 index 000000000..9c62b82bd --- /dev/null +++ b/app/features/tournament-bracket/core/AbDivisions.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import * as AbDivisions from "./AbDivisions"; + +describe("AbDivisions.validate", () => { + it("accepts a balanced 12-team single-group configuration", () => { + const result = AbDivisions.validate({ + abDivisionsBySeedOrder: [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1], + groupCount: 1, + }); + + expect(result.isOk()).toBe(true); + expect(result._unsafeUnwrap()).toEqual([ + 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, + ]); + }); + + it("accepts a balanced 12-team two-group configuration", () => { + const result = AbDivisions.validate({ + abDivisionsBySeedOrder: [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1], + groupCount: 2, + }); + + expect(result.isOk()).toBe(true); + }); + + it("rejects any unassigned team", () => { + const result = AbDivisions.validate({ + abDivisionsBySeedOrder: [0, 1, 0, 1, 0, null, 0, 1, 0, 1, 0, 1], + groupCount: 1, + }); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toMatch(/assigned/); + }); + + it("rejects invalid division values", () => { + const result = AbDivisions.validate({ + abDivisionsBySeedOrder: [0, 1, 0, 1, 0, 2, 0, 1, 0, 1, 0, 1], + groupCount: 1, + }); + + expect(result.isErr()).toBe(true); + }); + + it("rejects A/B counts differing by more than 1", () => { + const result = AbDivisions.validate({ + abDivisionsBySeedOrder: [0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1], + groupCount: 1, + }); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toMatch(/7 A, 5 B/); + }); + + it("accepts a ±1 uneven configuration with a single group", () => { + const result = AbDivisions.validate({ + abDivisionsBySeedOrder: [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0], + groupCount: 1, + }); + + expect(result.isOk()).toBe(true); + }); + + it("rejects a ±1 uneven configuration when there are multiple groups", () => { + const result = AbDivisions.validate({ + abDivisionsBySeedOrder: [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0], + groupCount: 2, + }); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toMatch(/single group/); + }); + + it("rejects team counts not divisible by group count", () => { + const result = AbDivisions.validate({ + abDivisionsBySeedOrder: [0, 1, 0, 1, 0, 1, 0, 1, 0, 1], + groupCount: 3, + }); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toMatch(/10 checked-in teams into 3/); + }); + + it("rejects odd per-group team counts", () => { + const result = AbDivisions.validate({ + abDivisionsBySeedOrder: [0, 1, 0, 1, 0, 1, 0, 1, 0, 1], + groupCount: 2, + }); + + expect(result.isErr()).toBe(true); + expect(result._unsafeUnwrapErr()).toMatch(/5 teams/); + }); + + it("preserves the original order of the divisions", () => { + const divisions = [1, 0, 1, 0, 0, 1, 1, 0]; + + const result = AbDivisions.validate({ + abDivisionsBySeedOrder: divisions, + groupCount: 2, + }); + + expect(result._unsafeUnwrap()).toEqual(divisions); + }); +}); + +describe("AbDivisions.countByDivision", () => { + it("counts A, B, and unassigned separately", () => { + const counts = AbDivisions.countByDivision([ + { abDivision: 0 }, + { abDivision: 0 }, + { abDivision: 1 }, + { abDivision: null }, + { abDivision: null }, + { abDivision: null }, + ]); + + expect(counts).toEqual({ a: 2, b: 1, unassigned: 3 }); + }); +}); diff --git a/app/features/tournament-bracket/core/AbDivisions.ts b/app/features/tournament-bracket/core/AbDivisions.ts new file mode 100644 index 000000000..936c7e632 --- /dev/null +++ b/app/features/tournament-bracket/core/AbDivisions.ts @@ -0,0 +1,85 @@ +import { err, ok, type Result } from "neverthrow"; + +interface ValidateArgs { + abDivisionsBySeedOrder: (number | null | undefined)[]; + groupCount: number; +} + +/** + * Validates that the checked-in teams are ready to start a bipartite (A/B) round robin bracket. + * + * Returns the division assignments parallel to the seeding order on success, or an error message + * suitable for surfacing to the organizer if any of the following are violated: + * + * - Every team has an A (0) or B (1) assignment + * - The counts of A and B teams differ by at most 1 + * - When the counts differ by 1, there must be only one group (uneven divisions can't be split + * evenly across multiple groups) + * - When the counts are equal, the total team count splits evenly across the groups and each + * group's team count is even (so A and B can be balanced within the group) + */ +export function validate({ + abDivisionsBySeedOrder, + groupCount, +}: ValidateArgs): Result<(0 | 1)[], string> { + const teamCount = abDivisionsBySeedOrder.length; + + const missingAssignment = abDivisionsBySeedOrder.some( + (division) => division !== 0 && division !== 1, + ); + if (missingAssignment) { + return err( + "Every checked-in team must be assigned to A or B before starting the bracket", + ); + } + + const aCount = abDivisionsBySeedOrder.filter( + (division) => division === 0, + ).length; + const bCount = abDivisionsBySeedOrder.filter( + (division) => division === 1, + ).length; + const diff = Math.abs(aCount - bCount); + + if (diff > 1) { + return err( + `Unbalanced A/B divisions (${aCount} A, ${bCount} B) — counts can differ by at most 1`, + ); + } + + if (diff === 1) { + if (groupCount !== 1) { + return err( + `Uneven A/B divisions (${aCount} A, ${bCount} B) are only supported with a single group`, + ); + } + + return ok(abDivisionsBySeedOrder as (0 | 1)[]); + } + + if (teamCount % groupCount !== 0) { + return err( + `Can't evenly distribute ${teamCount} checked-in teams into ${groupCount} groups`, + ); + } + + const teamsPerGroup = teamCount / groupCount; + if (teamsPerGroup % 2 !== 0) { + return err( + `Each group would have ${teamsPerGroup} teams — must be even for A/B divisions`, + ); + } + + return ok(abDivisionsBySeedOrder as (0 | 1)[]); +} + +/** Counts checked-in teams by division. Unassigned teams are excluded. */ +export function countByDivision(teams: { abDivision: number | null }[]) { + const a = teams.filter((team) => team.abDivision === 0).length; + const b = teams.filter((team) => team.abDivision === 1).length; + const unassigned = teams.filter( + (team) => team.abDivision !== 0 && team.abDivision !== 1, + ).length; + + return { a, b, unassigned }; +} diff --git a/app/features/tournament-bracket/core/Bracket.test.ts b/app/features/tournament-bracket/core/Bracket.test.ts index d0e2d9e41..88d1ef665 100644 --- a/app/features/tournament-bracket/core/Bracket.test.ts +++ b/app/features/tournament-bracket/core/Bracket.test.ts @@ -1,11 +1,13 @@ import * as R from "remeda"; import { describe, expect, it } from "vitest"; +import { BracketsManager } from "~/modules/brackets-manager"; +import { InMemoryDatabase } from "~/modules/brackets-memory-db"; import invariant from "../../../utils/invariant"; import * as Swiss from "../core/Swiss"; import { Tournament } from "./Tournament"; import { PADDLING_POOL_255 } from "./tests/mocks"; import { LOW_INK_DECEMBER_2024 } from "./tests/mocks-li"; -import { testTournament } from "./tests/test-utils"; +import { testTournament, tournamentCtxTeam } from "./tests/test-utils"; const TEAM_ERROR_404_ID = 17354; const TEAM_THIS_IS_FINE_ID = 17513; @@ -168,3 +170,124 @@ describe("round robin standings", () => { } }); }); + +describe("round robin A/B divisions standings", () => { + const abDivisionsTournament = () => { + const storage = new InMemoryDatabase(); + const manager = new BracketsManager(storage); + + manager.create({ + name: "AB RR", + tournamentId: 1, + type: "round_robin", + seeding: [1, 2, 3, 4], + abDivisions: [0, 1, 0, 1], + settings: { + groupCount: 1, + hasAbDivisions: true, + seedOrdering: ["groups.seed_optimized"], + }, + }); + + const setResult = ( + matchId: number, + winnerId: number, + winnerScore: number, + loserScore: number, + ) => { + const match = storage.select("match", matchId); + invariant(match, `match ${matchId} not found`); + const winnerIsOpp1 = match.opponent1.id === winnerId; + manager.update.match({ + id: match.id, + opponent1: winnerIsOpp1 + ? { score: winnerScore, result: "win" } + : { score: loserScore }, + opponent2: winnerIsOpp1 + ? { score: loserScore } + : { score: winnerScore, result: "win" }, + }); + }; + + const winnerByMatchup: Record = { + "1-2": 1, + "1-4": 1, + "2-3": 2, + "3-4": 3, + }; + for (const match of storage.select("match")!) { + const a = match.opponent1.id as number; + const b = match.opponent2.id as number; + const key = a < b ? `${a}-${b}` : `${b}-${a}`; + const winnerId = winnerByMatchup[key]; + invariant(winnerId, `unexpected matchup ${key}`); + const loserScore = key === "2-3" || key === "3-4" ? 1 : 0; + setResult(match.id, winnerId, 2, loserScore); + } + + const data = manager.get.tournamentData(1); + + return testTournament({ + ctx: { + settings: { + bracketProgression: [ + { + type: "round_robin", + name: "AB RR", + requiresCheckIn: false, + settings: { hasAbDivisions: true }, + }, + ], + }, + teams: [ + tournamentCtxTeam(1, { abDivision: 0, seed: 1 }), + tournamentCtxTeam(2, { abDivision: 1, seed: 2 }), + tournamentCtxTeam(3, { abDivision: 0, seed: 3 }), + tournamentCtxTeam(4, { abDivision: 1, seed: 4 }), + ], + }, + data, + }); + }; + + it("filtering by abDivision preserves standard tiebreaker order within each division", () => { + const tournament = abDivisionsTournament(); + const standings = tournament.bracketByIdx(0)!.currentStandings(true); + + expect(standings.map((s) => s.team.id)).toEqual([1, 2, 3, 4]); + + const divisionA = standings.filter((s) => s.team.abDivision === 0); + const divisionB = standings.filter((s) => s.team.abDivision === 1); + + expect(divisionA.map((s) => s.team.id)).toEqual([1, 3]); + expect(divisionB.map((s) => s.team.id)).toEqual([2, 4]); + }); + + it("source({ placements: [1] }) returns top team from each division", () => { + const tournament = abDivisionsTournament(); + const { teams } = tournament.bracketByIdx(0)!.source({ placements: [1] }); + + expect(teams).toEqual([1, 2]); + }); + + it("source({ placements: [1, 2] }) returns top two teams from each division", () => { + const tournament = abDivisionsTournament(); + const { teams } = tournament + .bracketByIdx(0)! + .source({ placements: [1, 2] }); + + expect(teams).toHaveLength(4); + expect(new Set(teams)).toEqual(new Set([1, 2, 3, 4])); + expect(teams.slice(0, 2)).toEqual([1, 3]); + expect(teams.slice(2, 4)).toEqual([2, 4]); + }); + + it("source ignores placements beyond division size", () => { + const tournament = abDivisionsTournament(); + const { teams } = tournament + .bracketByIdx(0)! + .source({ placements: [1, 5] }); + + expect(teams).toEqual([1, 2]); + }); +}); diff --git a/app/features/tournament-bracket/core/Bracket/Bracket.ts b/app/features/tournament-bracket/core/Bracket/Bracket.ts index 3fb29f9ba..56634639a 100644 --- a/app/features/tournament-bracket/core/Bracket/Bracket.ts +++ b/app/features/tournament-bracket/core/Bracket/Bracket.ts @@ -7,6 +7,7 @@ import type { Round } from "~/modules/brackets-model"; import invariant from "~/utils/invariant"; import { logger } from "~/utils/logger"; import { fillWithNullTillPowerOfTwo } from "../../tournament-bracket-utils"; +import * as AbDivisions from "../AbDivisions"; import { getTournamentManager } from "../brackets-manager"; import * as Progression from "../Progression"; import type { OptionalIdObject, Tournament } from "../Tournament"; @@ -294,6 +295,16 @@ export abstract class Bracket { const virtualTournamentId = 1; if (teams.length >= TOURNAMENT.ENOUGH_TEAMS_TO_START) { + const settings = this.tournament.bracketManagerSettings( + this.settings, + this.type, + teams.length, + ); + const abDivisions = + this.type === "round_robin" && this.settings?.hasAbDivisions === true + ? this.abDivisionsForPreview(teams, settings.groupCount) + : undefined; + manager.create({ tournamentId: virtualTournamentId, name: "Virtual", @@ -302,17 +313,58 @@ export abstract class Bracket { this.type === "round_robin" ? teams : fillWithNullTillPowerOfTwo(teams), - settings: this.tournament.bracketManagerSettings( - this.settings, - this.type, - teams.length, - ), + settings: abDivisions + ? settings + : { + ...settings, + hasAbDivisions: false, + }, + abDivisions, }); } return manager.get.tournamentData(virtualTournamentId); } + private abDivisionsForPreview( + teams: number[], + groupCount: number | undefined, + ): (0 | 1)[] | undefined { + if (!groupCount) return undefined; + + const assignments = teams.map((teamId) => { + const team = this.tournament.teamById(teamId); + return team?.abDivision ?? null; + }); + + const allAssigned = assignments.every( + (value) => value === 0 || value === 1, + ); + if ( + allAssigned && + AbDivisions.validate({ + abDivisionsBySeedOrder: assignments, + groupCount, + }).isOk() + ) { + return assignments as (0 | 1)[]; + } + + const fakeAssignments: (0 | 1)[] = teams.map((_, index) => + index % 2 === 0 ? 0 : 1, + ); + if ( + AbDivisions.validate({ + abDivisionsBySeedOrder: fakeAssignments, + groupCount, + }).isOk() + ) { + return fakeAssignments; + } + + return undefined; + } + get isUnderground() { return Progression.isUnderground( this.idx, diff --git a/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts b/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts index d3d612bcc..5b13a66cb 100644 --- a/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts +++ b/app/features/tournament-bracket/core/Bracket/RoundRobinBracket.ts @@ -23,6 +23,13 @@ export class RoundRobinBracket extends Bracket { const relevantMatchesFinished = standings.length === this.participantTournamentTeamIds.length; + if (this.settings?.hasAbDivisions) { + return { + relevantMatchesFinished, + teams: this.teamsFromPlacementsPerAbDivision(standings, placements), + }; + } + const uniquePlacements = R.unique(standings.map((s) => s.placement)); // 1,3,5 -> 1,2,3 e.g. @@ -38,6 +45,30 @@ export class RoundRobinBracket extends Bracket { }; } + private teamsFromPlacementsPerAbDivision( + standings: Standing[], + placements: number[], + ): number[] { + const groupIds = R.unique( + standings + .map((s) => s.groupId) + .filter((id): id is number => typeof id === "number"), + ); + const teams: number[] = []; + for (const groupId of groupIds) { + for (const division of [0, 1] as const) { + const divisionStandings = standings.filter( + (s) => s.groupId === groupId && s.team.abDivision === division, + ); + for (const placement of placements) { + const standing = divisionStandings[placement - 1]; + if (standing) teams.push(standing.team.id); + } + } + } + return teams; + } + get standings(): Standing[] { return this.currentStandings(); } diff --git a/app/features/tournament-bracket/core/Progression.test.ts b/app/features/tournament-bracket/core/Progression.test.ts index 02f913b8d..26051fdc1 100644 --- a/app/features/tournament-bracket/core/Progression.test.ts +++ b/app/features/tournament-bracket/core/Progression.test.ts @@ -427,6 +427,55 @@ describe("validatedSources - other rules", () => { expect(Array.isArray(result)).toBe(true); }); + it("flags TOO_MANY_PLACEMENTS on A/B divisions when placement exceeds per-division size", () => { + const error = getValidatedBrackets([ + { + settings: { + hasAbDivisions: true, + teamsPerGroup: 6, + }, + type: "round_robin", + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "0", + placements: "1,2,3,4", + }, + ], + }, + ]) as Progression.ValidationError; + + expect(error.type).toBe("TOO_MANY_PLACEMENTS"); + expect((error as any).bracketIdx).toEqual(1); + }); + + it("accepts A/B divisions placements up to per-division size", () => { + const result = getValidatedBrackets([ + { + settings: { + hasAbDivisions: true, + teamsPerGroup: 6, + }, + type: "round_robin", + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "0", + placements: "1,2,3", + }, + ], + }, + ]); + + expect(Array.isArray(result)).toBe(true); + }); + it("handles DUPLICATE_BRACKET_NAME", () => { const error = getValidatedBrackets([ { @@ -589,6 +638,165 @@ describe("validatedSources - other rules", () => { // Should be valid (no error returned) expect(Array.isArray(result)).toBe(true); }); + + it("accepts A/B divisions on a round robin starting bracket with even teamsPerGroup", () => { + const result = getValidatedBrackets([ + { + settings: { + hasAbDivisions: true, + teamsPerGroup: 6, + }, + type: "round_robin", + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "0", + placements: "1-2", + }, + ], + }, + ]); + + expect(Array.isArray(result)).toBe(true); + }); + + it("handles AB_DIVISIONS_NOT_ROUND_ROBIN", () => { + const error = getValidatedBrackets([ + { + settings: { + hasAbDivisions: true, + }, + type: "swiss", + name: "Swiss", + }, + { + settings: {}, + type: "single_elimination", + name: "Finals", + sources: [ + { + bracketId: "0", + placements: "1-2", + }, + ], + }, + ]) as Progression.ValidationError; + + expect(error.type).toBe("AB_DIVISIONS_NOT_ROUND_ROBIN"); + expect((error as any).bracketIdx).toEqual(0); + }); + + it("handles AB_DIVISIONS_NOT_STARTING", () => { + const error = getValidatedBrackets([ + { + settings: {}, + type: "round_robin", + name: "Group stage", + }, + { + settings: { + hasAbDivisions: true, + teamsPerGroup: 4, + }, + type: "round_robin", + name: "Second RR", + sources: [ + { + bracketId: "0", + placements: "1-2", + }, + ], + }, + { + settings: {}, + type: "single_elimination", + name: "Finals", + sources: [ + { + bracketId: "1", + placements: "1-2", + }, + ], + }, + ]) as Progression.ValidationError; + + expect(error.type).toBe("AB_DIVISIONS_NOT_STARTING"); + expect((error as any).bracketIdx).toEqual(1); + }); + + it("handles AB_DIVISIONS_ODD_TEAMS_PER_GROUP", () => { + const error = getValidatedBrackets([ + { + settings: { + hasAbDivisions: true, + teamsPerGroup: 5, + }, + type: "round_robin", + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "0", + placements: "1-2", + }, + ], + }, + ]) as Progression.ValidationError; + + expect(error.type).toBe("AB_DIVISIONS_ODD_TEAMS_PER_GROUP"); + expect((error as any).bracketIdx).toEqual(0); + }); + + it("accepts A/B divisions when teamsPerGroup is unset (default is even)", () => { + const result = getValidatedBrackets([ + { + settings: { + hasAbDivisions: true, + }, + type: "round_robin", + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "0", + placements: "1-2", + }, + ], + }, + ]); + + expect(Array.isArray(result)).toBe(true); + }); + + it("does not apply A/B validation when hasAbDivisions is absent", () => { + const result = getValidatedBrackets([ + { + settings: { + teamsPerGroup: 5, + }, + type: "round_robin", + }, + { + settings: {}, + type: "single_elimination", + sources: [ + { + bracketId: "0", + placements: "1-2", + }, + ], + }, + ]); + + expect(Array.isArray(result)).toBe(true); + }); }); describe("isFinals", () => { diff --git a/app/features/tournament-bracket/core/Progression.ts b/app/features/tournament-bracket/core/Progression.ts index 60117da01..3f6302bdd 100644 --- a/app/features/tournament-bracket/core/Progression.ts +++ b/app/features/tournament-bracket/core/Progression.ts @@ -97,6 +97,21 @@ export type ValidationError = | { type: "SWISS_EARLY_ADVANCE_NO_DESTINATION"; bracketIdx: number; + } + // A/B divisions setting is only valid on round robin brackets + | { + type: "AB_DIVISIONS_NOT_ROUND_ROBIN"; + bracketIdx: number; + } + // A/B divisions setting is only valid on starting brackets (no sources) + | { + type: "AB_DIVISIONS_NOT_STARTING"; + bracketIdx: number; + } + // A/B divisions requires an even teamsPerGroup so each group can be split equally + | { + type: "AB_DIVISIONS_ODD_TEAMS_PER_GROUP"; + bracketIdx: number; }; /** Takes validated brackets and returns them in the format that is ready for user input. */ @@ -274,6 +289,30 @@ export function bracketsToValidationError( }; } + faultyBracketIdx = abDivisionsOnNonRoundRobin(brackets); + if (typeof faultyBracketIdx === "number") { + return { + type: "AB_DIVISIONS_NOT_ROUND_ROBIN", + bracketIdx: faultyBracketIdx, + }; + } + + faultyBracketIdx = abDivisionsOnNonStartingBracket(brackets); + if (typeof faultyBracketIdx === "number") { + return { + type: "AB_DIVISIONS_NOT_STARTING", + bracketIdx: faultyBracketIdx, + }; + } + + faultyBracketIdx = abDivisionsOddTeamsPerGroup(brackets); + if (typeof faultyBracketIdx === "number") { + return { + type: "AB_DIVISIONS_ODD_TEAMS_PER_GROUP", + bracketIdx: faultyBracketIdx, + }; + } + return null; } @@ -474,9 +513,13 @@ function tooManyPlacements(brackets: ParsedBracket[]) { for (const source of bracket.sources ?? []) { if (!roundRobins.includes(source.bracketIdx)) continue; - const size = - brackets[source.bracketIdx].settings.teamsPerGroup ?? + const sourceSettings = brackets[source.bracketIdx].settings; + const teamsPerGroup = + sourceSettings.teamsPerGroup ?? TOURNAMENT.RR_DEFAULT_TEAM_COUNT_PER_GROUP; + const size = sourceSettings.hasAbDivisions + ? teamsPerGroup / 2 + : teamsPerGroup; if (source.placements.some((placement) => placement > size)) { return bracketIdx; @@ -546,6 +589,46 @@ function noDoubleEliminationPositive(brackets: ParsedBracket[]) { return null; } +function abDivisionsOnNonRoundRobin(brackets: ParsedBracket[]) { + for (const [bracketIdx, bracket] of brackets.entries()) { + if (bracket.settings.hasAbDivisions && bracket.type !== "round_robin") { + return bracketIdx; + } + } + + return null; +} + +function abDivisionsOnNonStartingBracket(brackets: ParsedBracket[]) { + for (const [bracketIdx, bracket] of brackets.entries()) { + if ( + bracket.settings.hasAbDivisions && + bracket.sources && + bracket.sources.length > 0 + ) { + return bracketIdx; + } + } + + return null; +} + +function abDivisionsOddTeamsPerGroup(brackets: ParsedBracket[]) { + for (const [bracketIdx, bracket] of brackets.entries()) { + if (!bracket.settings.hasAbDivisions) continue; + + const teamsPerGroup = + bracket.settings.teamsPerGroup ?? + TOURNAMENT.RR_DEFAULT_TEAM_COUNT_PER_GROUP; + + if (teamsPerGroup % 2 !== 0) { + return bracketIdx; + } + } + + return null; +} + function swissEarlyAdvanceWithoutDestination(brackets: ParsedBracket[]) { for (const [bracketIdx, bracket] of brackets.entries()) { if (bracket.type === "swiss" && bracket.settings.advanceThreshold) { diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index 63e08528f..7024cb7ce 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -556,6 +556,7 @@ export class Tournament { return { groupCount: Math.ceil(participantsCount / teamsPerGroup), seedOrdering: ["groups.seed_optimized"], + hasAbDivisions: selectedSettings?.hasAbDivisions ?? false, }; } case "swiss": { diff --git a/app/features/tournament-bracket/core/summarizer.test.ts b/app/features/tournament-bracket/core/summarizer.test.ts index 901d06f09..2f34b67cf 100644 --- a/app/features/tournament-bracket/core/summarizer.test.ts +++ b/app/features/tournament-bracket/core/summarizer.test.ts @@ -33,6 +33,7 @@ describe("tournamentSummary()", () => { inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, mapPool: [], members: userIds.map((userId) => ({ country: null, diff --git a/app/features/tournament-bracket/core/tests/mocks-li.ts b/app/features/tournament-bracket/core/tests/mocks-li.ts index 83e4b2223..9fbbbbe56 100644 --- a/app/features/tournament-bracket/core/tests/mocks-li.ts +++ b/app/features/tournament-bracket/core/tests/mocks-li.ts @@ -7255,6 +7255,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733157607, activeRosterUserIds: [25875, 21063, 11226, 31597], pickupAvatarUrl: null, @@ -7364,6 +7365,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733157629, activeRosterUserIds: [14837, 27260, 42704, 9379], pickupAvatarUrl: null, @@ -7473,6 +7475,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 1, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733161494, activeRosterUserIds: [34424, 31195, 31395, 26103], pickupAvatarUrl: null, @@ -7582,6 +7585,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733166918, activeRosterUserIds: null, pickupAvatarUrl: null, @@ -7675,6 +7679,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733166213, activeRosterUserIds: [32160, 29267, 25591, 36962], pickupAvatarUrl: null, @@ -7779,6 +7784,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 1, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733189945, activeRosterUserIds: [12418, 34355, 2319, 7430], pickupAvatarUrl: null, @@ -7888,6 +7894,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733244862, activeRosterUserIds: [29425, 31524, 35674, 26285], pickupAvatarUrl: null, @@ -7997,6 +8004,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733282085, activeRosterUserIds: [26747, 27292, 5708, 6309], pickupAvatarUrl: null, @@ -8122,6 +8130,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733291438, activeRosterUserIds: [24459, 40851, 23974, 43608], pickupAvatarUrl: null, @@ -8247,6 +8256,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 1, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733439755, activeRosterUserIds: null, pickupAvatarUrl: null, @@ -8340,6 +8350,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733485884, activeRosterUserIds: [30686, 1961, 30685, 22396], pickupAvatarUrl: null, @@ -8465,6 +8476,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733937993, activeRosterUserIds: [12434, 30263, 5861, 24275], pickupAvatarUrl: null, @@ -8590,6 +8602,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733166818, activeRosterUserIds: [32670, 38046, 42638, 34589], pickupAvatarUrl: "pickup-logo-Hj-Us_Roj5Ksfv000ceBo-1733166818832.webp", @@ -8699,6 +8712,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733167616, activeRosterUserIds: [45102, 26711, 41739, 4533], pickupAvatarUrl: null, @@ -8808,6 +8822,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 1, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733201503, activeRosterUserIds: [20807, 31556, 33373, 42703], pickupAvatarUrl: null, @@ -8933,6 +8948,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733218069, activeRosterUserIds: [26509, 7959, 7690, 7958], pickupAvatarUrl: null, @@ -9042,6 +9058,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733319202, activeRosterUserIds: [10714, 21685, 8840, 10028], pickupAvatarUrl: null, @@ -9167,6 +9184,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 1, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733471556, activeRosterUserIds: [17532, 30204, 36007, 38896], pickupAvatarUrl: null, @@ -9276,6 +9294,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733501938, activeRosterUserIds: [30495, 43073, 30488, 45295], pickupAvatarUrl: null, @@ -9385,6 +9404,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733622364, activeRosterUserIds: null, pickupAvatarUrl: null, @@ -9472,6 +9492,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733635706, activeRosterUserIds: null, pickupAvatarUrl: null, @@ -9565,6 +9586,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733671856, activeRosterUserIds: null, pickupAvatarUrl: null, @@ -9653,6 +9675,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733810204, activeRosterUserIds: [1959, 17352, 33954, 22403], pickupAvatarUrl: "pickup-logo-3KZntw8OZ9LkW4XqZRLS9-1733810204048.webp", @@ -9757,6 +9780,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733889961, activeRosterUserIds: [6696, 32107, 33402, 30619], pickupAvatarUrl: null, @@ -9861,6 +9885,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733892132, activeRosterUserIds: [21670, 8993, 8395, 3566], pickupAvatarUrl: null, @@ -9970,6 +9995,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1734035170, activeRosterUserIds: [24510, 10670, 22577, 31143], pickupAvatarUrl: null, @@ -10079,6 +10105,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1734107844, activeRosterUserIds: [28170, 14309, 17310, 23164], pickupAvatarUrl: null, @@ -10188,6 +10215,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1734132225, activeRosterUserIds: null, pickupAvatarUrl: "pickup-logo-_asHjlVchhJ50PH_mDBtw-1734132224819.webp", @@ -10276,6 +10304,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733194304, activeRosterUserIds: [40505, 29011, 23082, 45036], pickupAvatarUrl: null, @@ -10385,6 +10414,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733195091, activeRosterUserIds: null, pickupAvatarUrl: null, @@ -10478,6 +10508,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733364647, activeRosterUserIds: [22801, 31150, 35354, 27747], pickupAvatarUrl: null, @@ -10587,6 +10618,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733374295, activeRosterUserIds: null, pickupAvatarUrl: null, @@ -10680,6 +10712,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733433864, activeRosterUserIds: null, pickupAvatarUrl: null, @@ -10799,6 +10832,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733513814, activeRosterUserIds: null, pickupAvatarUrl: null, @@ -10892,6 +10926,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733602400, activeRosterUserIds: [10826, 4248, 20419, 11180], pickupAvatarUrl: null, @@ -11001,6 +11036,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733753214, activeRosterUserIds: [27903, 28446, 34634, 30728], pickupAvatarUrl: null, @@ -11110,6 +11146,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733914001, activeRosterUserIds: [32909, 10190, 35922, 40304], pickupAvatarUrl: null, @@ -11235,6 +11272,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733966548, activeRosterUserIds: [35617, 37669, 37436, 35811], pickupAvatarUrl: null, @@ -11344,6 +11382,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1734032213, activeRosterUserIds: null, pickupAvatarUrl: null, @@ -11437,6 +11476,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1734106606, activeRosterUserIds: [37173, 43269, 43623, 16054], pickupAvatarUrl: null, @@ -11546,6 +11586,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1734116765, activeRosterUserIds: [25312, 10378, 46771, 26044], pickupAvatarUrl: null, @@ -11671,6 +11712,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1734125312, activeRosterUserIds: null, pickupAvatarUrl: null, @@ -11764,6 +11806,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1734134382, activeRosterUserIds: [26758, 25689, 42164, 44475], pickupAvatarUrl: null, @@ -11868,6 +11911,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733156802, activeRosterUserIds: [9036, 7434, 3738, 9112], pickupAvatarUrl: null, @@ -11977,6 +12021,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733157391, activeRosterUserIds: [5935, 38204, 3741, 8080], pickupAvatarUrl: null, @@ -12102,6 +12147,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733162274, activeRosterUserIds: null, pickupAvatarUrl: null, @@ -12195,6 +12241,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733367806, activeRosterUserIds: null, pickupAvatarUrl: null, @@ -12288,6 +12335,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733456080, activeRosterUserIds: [10386, 33369, 29617, 22942], pickupAvatarUrl: null, @@ -12397,6 +12445,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733579092, activeRosterUserIds: null, pickupAvatarUrl: null, @@ -12484,6 +12533,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733769667, activeRosterUserIds: [3481, 38022, 41269, 43551], pickupAvatarUrl: null, @@ -12609,6 +12659,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733794148, activeRosterUserIds: [22820, 29636, 27036, 28959], pickupAvatarUrl: null, @@ -12734,6 +12785,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 1, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733820540, activeRosterUserIds: null, pickupAvatarUrl: "pickup-logo-t2-mrQNINFqIoFNYuxbmW-1733820600291.webp", @@ -12822,6 +12874,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733825084, activeRosterUserIds: null, pickupAvatarUrl: null, @@ -12915,6 +12968,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 1, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733865890, activeRosterUserIds: [15425, 41975, 28938, 8587], pickupAvatarUrl: null, @@ -13024,6 +13078,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 1, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733873149, activeRosterUserIds: [40550, 7115, 29674, 30031], pickupAvatarUrl: null, @@ -13149,6 +13204,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733875608, activeRosterUserIds: null, pickupAvatarUrl: null, @@ -13242,6 +13298,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733888417, activeRosterUserIds: null, pickupAvatarUrl: "pickup-logo-c9a1igcMT4m2otyRdTs_0-1733888672873.webp", @@ -13330,6 +13387,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 1, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1734008857, activeRosterUserIds: [30266, 37341, 22699, 28145], pickupAvatarUrl: null, @@ -13439,6 +13497,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 1, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1734018352, activeRosterUserIds: null, pickupAvatarUrl: null, @@ -13532,6 +13591,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1734019701, activeRosterUserIds: [35421, 33524, 22500, 32802], pickupAvatarUrl: "pickup-logo-u4oKxXYjamTXZ1x-bgNFp-1734019701188.webp", @@ -13652,6 +13712,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 1, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1734023441, activeRosterUserIds: [1852, 2898, 25763, 3466], pickupAvatarUrl: null, @@ -13761,6 +13822,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1734099744, activeRosterUserIds: [39098, 22624, 28137, 2769], pickupAvatarUrl: null, @@ -13870,6 +13932,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1734109256, activeRosterUserIds: [29661, 15158, 35067, 31655], pickupAvatarUrl: "pickup-logo-An13SrR78qDNIM2t95ujb-1734109256283.webp", @@ -13990,6 +14053,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1734125682, activeRosterUserIds: [36575, 30425, 32430, 24290], pickupAvatarUrl: null, @@ -14115,6 +14179,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 1, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733515005, activeRosterUserIds: null, pickupAvatarUrl: null, @@ -14208,6 +14273,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733521735, activeRosterUserIds: [44772, 38912, 36853, 42599], pickupAvatarUrl: null, @@ -14317,6 +14383,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733525617, activeRosterUserIds: null, pickupAvatarUrl: null, @@ -14410,6 +14477,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733847805, activeRosterUserIds: [33615, 32015, 45778, 32970], pickupAvatarUrl: null, @@ -14519,6 +14587,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733858126, activeRosterUserIds: [34545, 35567, 41108, 41255], pickupAvatarUrl: null, @@ -14628,6 +14697,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733966096, activeRosterUserIds: [39470, 42874, 32878, 25741], pickupAvatarUrl: null, @@ -14737,6 +14807,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1734021147, activeRosterUserIds: [45250, 45174, 6976, 10222], pickupAvatarUrl: "pickup-logo-v3boyVjbFsTyMlQylz4Dn-1734021152539.webp", @@ -14846,6 +14917,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1734040772, activeRosterUserIds: null, pickupAvatarUrl: "pickup-logo-Jx6JnhFJQjOnM10s_79ld-1734041234919.webp", @@ -14939,6 +15011,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 1, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1734033803, activeRosterUserIds: [27800, 12235, 30044, 29531], pickupAvatarUrl: null, @@ -15048,6 +15121,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1734099612, activeRosterUserIds: [24572, 7058, 37641, 33913], pickupAvatarUrl: "pickup-logo-RrPQW5kG_K1cvjdU5TKcF-1734099611923.webp", @@ -15152,6 +15226,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 1, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1734113463, activeRosterUserIds: null, pickupAvatarUrl: null, @@ -15245,6 +15320,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1734118202, activeRosterUserIds: null, pickupAvatarUrl: null, @@ -15332,6 +15408,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1734134334, activeRosterUserIds: [11186, 27611, 25952, 23481], pickupAvatarUrl: null, @@ -15441,6 +15518,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 1, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733169181, activeRosterUserIds: null, pickupAvatarUrl: null, @@ -15534,6 +15612,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 1, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733247691, activeRosterUserIds: null, pickupAvatarUrl: "pickup-logo--fZF6IGlzuuHeotc6Z00p-1733762912138.webp", @@ -15627,6 +15706,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733452618, activeRosterUserIds: null, pickupAvatarUrl: null, @@ -15725,6 +15805,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733481710, activeRosterUserIds: null, pickupAvatarUrl: null, @@ -15828,6 +15909,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733508949, activeRosterUserIds: null, pickupAvatarUrl: null, @@ -15921,6 +16003,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733611261, activeRosterUserIds: null, pickupAvatarUrl: null, @@ -16009,6 +16092,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 0, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733841846, activeRosterUserIds: [41943, 46289, 45290, 46394], pickupAvatarUrl: null, @@ -16118,6 +16202,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 1, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1733878153, activeRosterUserIds: null, pickupAvatarUrl: null, @@ -16211,6 +16296,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ droppedOut: 1, inviteCode: null, startingBracketIdx: null, + abDivision: null, createdAt: 1734135144, activeRosterUserIds: null, pickupAvatarUrl: "pickup-logo-obQfxdRnJg0CsbrE6OXdl-1734135144301.webp", diff --git a/app/features/tournament-bracket/core/tests/mocks-sos.ts b/app/features/tournament-bracket/core/tests/mocks-sos.ts index 86f95b499..8004c0919 100644 --- a/app/features/tournament-bracket/core/tests/mocks-sos.ts +++ b/app/features/tournament-bracket/core/tests/mocks-sos.ts @@ -2498,6 +2498,7 @@ export const SWIM_OR_SINK_167 = ( team: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14800, @@ -2606,6 +2607,7 @@ export const SWIM_OR_SINK_167 = ( team: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14743, @@ -2730,6 +2732,7 @@ export const SWIM_OR_SINK_167 = ( team: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14801, @@ -2838,6 +2841,7 @@ export const SWIM_OR_SINK_167 = ( team: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14792, @@ -2946,6 +2950,7 @@ export const SWIM_OR_SINK_167 = ( team: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14670, @@ -3091,6 +3096,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14661, @@ -3220,6 +3226,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14804, @@ -3349,6 +3356,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14732, @@ -3462,6 +3470,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14747, @@ -3586,6 +3595,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14748, @@ -3710,6 +3720,7 @@ export const SWIM_OR_SINK_167 = ( team: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14803, @@ -3818,6 +3829,7 @@ export const SWIM_OR_SINK_167 = ( team: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14795, @@ -3947,6 +3959,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14445, @@ -4092,6 +4105,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14810, @@ -4158,6 +4172,7 @@ export const SWIM_OR_SINK_167 = ( team: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14777, @@ -4266,6 +4281,7 @@ export const SWIM_OR_SINK_167 = ( team: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14805, @@ -4374,6 +4390,7 @@ export const SWIM_OR_SINK_167 = ( team: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14806, @@ -4482,6 +4499,7 @@ export const SWIM_OR_SINK_167 = ( team: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14653, @@ -4627,6 +4645,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14708, @@ -4756,6 +4775,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14715, @@ -4869,6 +4889,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14796, @@ -4977,6 +4998,7 @@ export const SWIM_OR_SINK_167 = ( team: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14797, @@ -5085,6 +5107,7 @@ export const SWIM_OR_SINK_167 = ( team: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14750, @@ -5188,6 +5211,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14809, @@ -5317,6 +5341,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14709, @@ -5430,6 +5455,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14783, @@ -5485,6 +5511,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14702, @@ -5593,6 +5620,7 @@ export const SWIM_OR_SINK_167 = ( team: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14735, @@ -5722,6 +5750,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14798, @@ -5846,6 +5875,7 @@ export const SWIM_OR_SINK_167 = ( team: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14663, @@ -5959,6 +5989,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14517, @@ -6088,6 +6119,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14741, @@ -6217,6 +6249,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14711, @@ -6320,6 +6353,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14655, @@ -6465,6 +6499,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14634, @@ -6552,6 +6587,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14799, @@ -6665,6 +6701,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14784, @@ -6747,6 +6784,7 @@ export const SWIM_OR_SINK_167 = ( team: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14733, @@ -6855,6 +6893,7 @@ export const SWIM_OR_SINK_167 = ( team: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14611, @@ -6958,6 +6997,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14713, @@ -7082,6 +7122,7 @@ export const SWIM_OR_SINK_167 = ( team: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14687, @@ -7211,6 +7252,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14742, @@ -7356,6 +7398,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14764, @@ -7464,6 +7507,7 @@ export const SWIM_OR_SINK_167 = ( team: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14802, @@ -7588,6 +7632,7 @@ export const SWIM_OR_SINK_167 = ( team: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14696, @@ -7717,6 +7762,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14808, @@ -7825,6 +7871,7 @@ export const SWIM_OR_SINK_167 = ( team: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14620, @@ -7965,6 +8012,7 @@ export const SWIM_OR_SINK_167 = ( team: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14737, @@ -8094,6 +8142,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14503, @@ -8181,6 +8230,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14739, @@ -8289,6 +8339,7 @@ export const SWIM_OR_SINK_167 = ( team: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, { id: 14607, @@ -8402,6 +8453,7 @@ export const SWIM_OR_SINK_167 = ( }, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, }, ], tieBreakerMapPool: [], 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 d714c0302..a9718d5e4 100644 --- a/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts +++ b/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts @@ -389,6 +389,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({ createdAt: 1734656039, activeRosterUserIds: [5662, 2899, 6114, 30176], startingBracketIdx: null, + abDivision: null, pickupAvatarUrl: null, members: [ { @@ -498,6 +499,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({ createdAt: 1734423187, activeRosterUserIds: null, startingBracketIdx: null, + abDivision: null, pickupAvatarUrl: "pickup-logo-rZYQMu8ELjiFkeiAVGJUt-1734424882431.webp", members: [ { @@ -586,6 +588,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({ createdAt: 1734660846, activeRosterUserIds: null, startingBracketIdx: null, + abDivision: null, pickupAvatarUrl: null, members: [ { @@ -674,6 +677,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({ createdAt: 1734683349, activeRosterUserIds: [37632, 13590, 10757, 33047], startingBracketIdx: null, + abDivision: null, pickupAvatarUrl: null, members: [ { @@ -783,6 +787,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({ createdAt: 1734608907, activeRosterUserIds: [11780, 46006, 43518, 33483], startingBracketIdx: null, + abDivision: null, pickupAvatarUrl: "pickup-logo-FOfFcEbo2OJxIJIJxNJqu-1734608907317.webp", members: [ { @@ -903,6 +908,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({ createdAt: 1734397954, activeRosterUserIds: [46467, 46813, 33491, 43662], startingBracketIdx: null, + abDivision: null, pickupAvatarUrl: "pickup-logo-y79k_HOVmjv4KfhTjuSqh-1734398099266.webp", members: [ { @@ -1012,6 +1018,7 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({ createdAt: 1734598652, activeRosterUserIds: null, startingBracketIdx: null, + abDivision: null, pickupAvatarUrl: "pickup-logo-IGXFtjFMa_dxQqAe2dqIR-1734598652684.webp", members: [ { diff --git a/app/features/tournament-bracket/core/tests/mocks.ts b/app/features/tournament-bracket/core/tests/mocks.ts index 6b405608a..eacb0cad1 100644 --- a/app/features/tournament-bracket/core/tests/mocks.ts +++ b/app/features/tournament-bracket/core/tests/mocks.ts @@ -1527,6 +1527,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709743534, @@ -1653,6 +1654,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709737918, @@ -1795,6 +1797,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709743523, @@ -1921,6 +1924,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709743262, @@ -2047,6 +2051,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709741396, @@ -2189,6 +2194,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709711811, @@ -2331,6 +2337,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709738831, @@ -2473,6 +2480,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709737837, @@ -2599,6 +2607,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709741719, @@ -2757,6 +2766,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709730354, @@ -2899,6 +2909,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709745630, @@ -3039,6 +3050,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709592381, @@ -3197,6 +3209,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709723749, @@ -3339,6 +3352,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709668399, @@ -3497,6 +3511,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709735267, @@ -3628,6 +3643,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709745849, @@ -3759,6 +3775,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709742258, @@ -3899,6 +3916,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709738744, @@ -4041,6 +4059,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709746054, @@ -4170,6 +4189,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709744894, @@ -4296,6 +4316,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709728278, @@ -4422,6 +4443,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709715006, @@ -4548,6 +4570,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709660578, @@ -4679,6 +4702,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709721869, @@ -4824,6 +4848,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709743633, @@ -4955,6 +4980,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709738747, @@ -5102,6 +5128,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709626047, @@ -5244,6 +5271,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709727951, @@ -5370,6 +5398,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709741482, @@ -5526,6 +5555,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709744451, @@ -5657,6 +5687,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709726536, @@ -5783,6 +5814,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709558706, @@ -5914,6 +5946,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709744323, @@ -6061,6 +6094,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709677397, @@ -6187,6 +6221,7 @@ export const PADDLING_POOL_257 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1709618711, @@ -8118,6 +8153,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708476597, @@ -8244,6 +8280,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708535137, @@ -8370,6 +8407,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708533764, @@ -8510,6 +8548,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708537512, @@ -8652,6 +8691,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708533309, @@ -8778,6 +8818,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708430641, @@ -8920,6 +8961,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708536306, @@ -9044,6 +9086,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708526368, @@ -9170,6 +9213,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708506060, @@ -9328,6 +9372,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708526814, @@ -9452,6 +9497,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708466421, @@ -9594,6 +9640,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708377426, @@ -9734,6 +9781,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708448289, @@ -9892,6 +9940,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708532602, @@ -10018,6 +10067,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708535205, @@ -10160,6 +10210,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708515945, @@ -10286,6 +10337,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708453334, @@ -10410,6 +10462,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708522730, @@ -10552,6 +10605,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708375443, @@ -10694,6 +10748,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708532665, @@ -10825,6 +10880,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708364254, @@ -10972,6 +11028,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708464101, @@ -11117,6 +11174,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708520249, @@ -11259,6 +11317,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708535804, @@ -11385,6 +11444,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708535891, @@ -11527,6 +11587,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708521749, @@ -11669,6 +11730,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708536584, @@ -11795,6 +11857,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708537772, @@ -11958,6 +12021,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708379916, @@ -12100,6 +12164,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708519753, @@ -12247,6 +12312,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708534312, @@ -12392,6 +12458,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708531929, @@ -12518,6 +12585,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708477155, @@ -12649,6 +12717,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708531564, @@ -12828,6 +12897,7 @@ export const PADDLING_POOL_255 = () => inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1708503356, @@ -15021,6 +15091,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707443313, @@ -15134,6 +15205,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707366405, @@ -15247,6 +15319,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1706912643, @@ -15360,6 +15433,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707359335, @@ -15505,6 +15579,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707171426, @@ -15634,6 +15709,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707342696, @@ -15763,6 +15839,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707513942, @@ -15908,6 +15985,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707526815, @@ -16053,6 +16131,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707583385, @@ -16166,6 +16245,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707486395, @@ -16279,6 +16359,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707513290, @@ -16392,6 +16473,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707531084, @@ -16505,6 +16587,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707568466, @@ -16634,6 +16717,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707481625, @@ -16747,6 +16831,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707530166, @@ -16860,6 +16945,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707181792, @@ -16989,6 +17075,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707550321, @@ -17123,6 +17210,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707575096, @@ -17252,6 +17340,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707569490, @@ -17397,6 +17486,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707537425, @@ -17510,6 +17600,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707564691, @@ -17660,6 +17751,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707145818, @@ -17783,6 +17875,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707558330, @@ -17896,6 +17989,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707586842, @@ -18009,6 +18103,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707583597, @@ -18154,6 +18249,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707429804, @@ -18283,6 +18379,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707539973, @@ -18417,6 +18514,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707507831, @@ -18546,6 +18644,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707586297, @@ -18673,6 +18772,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707583885, @@ -18818,6 +18918,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707578076, @@ -18950,6 +19051,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707582953, @@ -19063,6 +19165,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707575330, @@ -19199,6 +19302,7 @@ export const IN_THE_ZONE_32 = ({ inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, activeRosterUserIds: [], pickupAvatarUrl: null, createdAt: 1707527645, diff --git a/app/features/tournament-bracket/core/tests/test-utils.ts b/app/features/tournament-bracket/core/tests/test-utils.ts index b06238715..05afc0675 100644 --- a/app/features/tournament-bracket/core/tests/test-utils.ts +++ b/app/features/tournament-bracket/core/tests/test-utils.ts @@ -15,6 +15,7 @@ export const tournamentCtxTeam = ( inviteCode: null, avgSeedingSkillOrdinal: null, startingBracketIdx: null, + abDivision: null, team: null, mapPool: [], members: [], diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index 81dc1284f..7b21d0b9f 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -40,8 +40,10 @@ import { Bracket } from "../components/Bracket"; import { useBracketSpoilerCensor } from "../components/Bracket/useBracketSpoilerCensor"; import { BracketMapListDialog } from "../components/BracketMapListDialog"; import { TournamentTeamActions } from "../components/TournamentTeamActions"; +import * as AbDivisions from "../core/AbDivisions"; import type { Bracket as BracketType } from "../core/Bracket"; import * as PreparedMaps from "../core/PreparedMaps"; +import type { Tournament } from "../core/Tournament"; export { action }; @@ -181,10 +183,24 @@ export default function TournamentBracketsPage() { return null; } + const abDivisionsStartError = getAbDivisionsStartError(bracket, tournament); + return (
{bracket.preview && + tournament.isOrganizer(user) && + tournament.regularCheckInHasEnded && + abDivisionsStartError ? ( +
+ +
+ {abDivisionsStartError} +
+
+
+ ) : null} + {bracket.preview && bracket.enoughTeams && tournament.isOrganizer(user) && tournament.regularCheckInStartInThePast ? ( @@ -201,7 +217,11 @@ export default function TournamentBracketsPage() { tournament.isDraft ? ( ) : ( - + ) ) : null} @@ -269,12 +289,41 @@ export default function TournamentBracketsPage() { ); } +function getAbDivisionsStartError( + bracket: BracketType, + tournament: Tournament, +): string | null { + if ( + bracket.type !== "round_robin" || + !bracket.settings?.hasAbDivisions || + !bracket.isStartingBracket || + !bracket.seeding || + bracket.seeding.length === 0 + ) { + return null; + } + + const groupCount = new Set(bracket.data.round.map((r) => r.group_id)).size; + const abDivisionsBySeedOrder = bracket.seeding.map( + (teamId) => tournament.teamById(teamId)?.abDivision, + ); + + const result = AbDivisions.validate({ + abDivisionsBySeedOrder, + groupCount, + }); + + return result.isErr() ? result.error : null; +} + function BracketStarter({ bracket, bracketIdx, + isDisabled, }: { bracket: BracketType; bracketIdx: number; + isDisabled?: boolean; }) { const [dialogOpen, setDialogOpen] = React.useState(false); const isHydrated = useHydrated(); @@ -297,6 +346,7 @@ function BracketStarter({ size="small" data-testid="finalize-bracket-button" onPress={() => setDialogOpen(true)} + isDisabled={isDisabled} > Start the bracket diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index ad440796f..5b76db9cb 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -150,6 +150,7 @@ export async function findById(id: number) { "TournamentTeam.createdAt", "TournamentTeam.activeRosterUserIds", "TournamentTeam.startingBracketIdx", + "TournamentTeam.abDivision", concatUserSubmittedImagePrefix( innerEb.ref("UserSubmittedImage.url"), ).as("pickupAvatarUrl"), diff --git a/app/features/tournament/TournamentTeamRepository.server.ts b/app/features/tournament/TournamentTeamRepository.server.ts index b86f4c04c..0661e20e4 100644 --- a/app/features/tournament/TournamentTeamRepository.server.ts +++ b/app/features/tournament/TournamentTeamRepository.server.ts @@ -380,6 +380,35 @@ export function updateStartingBrackets( }); } +export function updateAbDivisions( + abDivisions: { + tournamentTeamId: number; + abDivision: 0 | 1 | null; + }[], +) { + const grouped = Object.groupBy(abDivisions, (ab) => String(ab.abDivision)); + + return db.transaction().execute(async (trx) => { + for (const [abDivisionKey, tournamentTeams = []] of Object.entries( + grouped, + )) { + if (tournamentTeams.length === 0) continue; + + await trx + .updateTable("TournamentTeam") + .set({ + abDivision: abDivisionKey === "null" ? null : Number(abDivisionKey), + }) + .where( + "TournamentTeam.id", + "in", + tournamentTeams.map((t) => t.tournamentTeamId), + ) + .execute(); + } + }); +} + /** * Checks in a tournament team. Clears any existing check-out records before inserting the check-in. * When called without `bracketIdx`, checks in for the whole tournament. diff --git a/app/features/tournament/actions/to.$id.seeds.server.ts b/app/features/tournament/actions/to.$id.seeds.server.ts index d27501387..df4539e07 100644 --- a/app/features/tournament/actions/to.$id.seeds.server.ts +++ b/app/features/tournament/actions/to.$id.seeds.server.ts @@ -68,6 +68,23 @@ export const action: ActionFunction = async ({ request, params }) => { ); break; } + case "UPDATE_AB_DIVISIONS": { + errorToastIfFalsy( + tournament.ctx.settings.bracketProgression.some( + (bracket) => !bracket.sources && bracket.settings?.hasAbDivisions, + ), + "No starting bracket has A/B divisions enabled", + ); + + const validTeamIds = new Set(tournament.ctx.teams.map((t) => t.id)); + errorToastIfFalsy( + data.abDivisions.every((t) => validTeamIds.has(t.tournamentTeamId)), + "Invalid tournament team id", + ); + + await TournamentTeamRepository.updateAbDivisions(data.abDivisions); + break; + } } clearTournamentDataCache(tournamentId); diff --git a/app/features/tournament/routes/to.$id.seeds.tsx b/app/features/tournament/routes/to.$id.seeds.tsx index df87d5f6c..b74416eca 100644 --- a/app/features/tournament/routes/to.$id.seeds.tsx +++ b/app/features/tournament/routes/to.$id.seeds.tsx @@ -23,23 +23,36 @@ import { Alert } from "~/components/Alert"; import { Avatar } from "~/components/Avatar"; import { Catcher } from "~/components/Catcher"; import { SendouButton } from "~/components/elements/Button"; +import { + SendouChipRadio, + SendouChipRadioGroup, +} from "~/components/elements/ChipRadio"; import { SendouDialog } from "~/components/elements/Dialog"; import { Image } from "~/components/Image"; import { InfoPopover } from "~/components/InfoPopover"; import { SubmitButton } from "~/components/SubmitButton"; import { Table } from "~/components/Table"; import type { SeedingSnapshot } from "~/db/tables"; +import * as AbDivisions from "~/features/tournament-bracket/core/AbDivisions"; +import type { Tournament } from "~/features/tournament-bracket/core/Tournament"; import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server"; import invariant from "~/utils/invariant"; import { navIconUrl, userResultsPage } from "~/utils/urls"; import { ordinalToRoundedSp } from "../../mmr/mmr-utils"; import { action } from "../actions/to.$id.seeds.server"; import { loader } from "../loaders/to.$id.seeds.server"; +import { TOURNAMENT } from "../tournament-constants"; import { useTournament } from "./to.$id"; import styles from "./to.$id.seeds.module.css"; export { action, loader }; +const AB_DIVISION_RADIO_OPTIONS = [ + { value: "unassigned", label: "Unassigned" }, + { value: "0", label: "A" }, + { value: "1", label: "B" }, +] as const; + export default function TournamentSeedsPage() { const tournament = useTournament(); const navigation = useNavigation(); @@ -161,6 +174,16 @@ export default function TournamentSeedsPage() { .join()} /> ) : null} + {hasAbDivisionsStartingBracket(tournament) ? ( + <> + team.abDivision ?? -1) + .join()} + /> + + + ) : null}
  • @@ -431,6 +454,183 @@ function StartingBracketDialog() { ); } +function hasAbDivisionsStartingBracket(tournament: Tournament) { + return tournament.ctx.settings.bracketProgression.some( + (bracket) => !bracket.sources && bracket.settings?.hasAbDivisions, + ); +} + +function AbDivisionImbalanceWarning() { + const tournament = useTournament(); + + const warnings = tournament.ctx.settings.bracketProgression + .map((bracket, bracketIdx) => { + if (bracket.sources || !bracket.settings?.hasAbDivisions) return null; + + const bracketTeams = tournament.isMultiStartingBracket + ? tournament.ctx.teams.filter( + (team) => (team.startingBracketIdx ?? 0) === bracketIdx, + ) + : tournament.ctx.teams; + const checkedInTeams = bracketTeams.filter( + (team) => team.checkIns.length > 0, + ); + + const { a, b } = AbDivisions.countByDivision(checkedInTeams); + const diff = Math.abs(a - b); + + const teamsPerGroup = + bracket.settings.teamsPerGroup ?? + TOURNAMENT.RR_DEFAULT_TEAM_COUNT_PER_GROUP; + const groupCount = Math.max( + 1, + Math.ceil(checkedInTeams.length / teamsPerGroup), + ); + + const tooImbalanced = diff > 1; + const unevenWithMultipleGroups = diff === 1 && groupCount > 1; + if (!tooImbalanced && !unevenWithMultipleGroups) return null; + + const prefix = tournament.isMultiStartingBracket + ? `${bracket.name}: ` + : ""; + const reason = tooImbalanced + ? "counts can differ by at most 1 to start bracket" + : "uneven A/B is only allowed with a single group"; + + return `${prefix}${a} checked-in A teams, ${b} checked-in B teams — ${reason}.`; + }) + .filter((warning): warning is string => warning !== null); + + if (warnings.length === 0) return null; + + return ( + +
    + {warnings.map((warning) => ( +
    {warning}
    + ))} +
    +
    + ); +} + +type AbDivisionValue = 0 | 1 | null; + +function AbDivisionsDialog() { + const fetcher = useFetcher(); + const tournament = useTournament(); + + const [isOpen, setIsOpen] = React.useState(false); + const [teamAbDivisions, setTeamAbDivisions] = React.useState< + { tournamentTeamId: number; abDivision: AbDivisionValue }[] + >( + tournament.ctx.teams.map((team) => ({ + tournamentTeamId: team.id, + abDivision: + team.abDivision === 0 || team.abDivision === 1 ? team.abDivision : null, + })), + ); + + const counts = AbDivisions.countByDivision(teamAbDivisions); + + return ( +
    + setIsOpen(true)} + data-testid="set-ab-divisions" + > + Set A/B divisions + + setIsOpen(false)} + isFullScreen + > + +
    + A: {counts.a} + B: {counts.b} + Unassigned: {counts.unassigned} +
    + + + +
Team
+ + + + + + + + {tournament.ctx.teams.map((team) => { + const { abDivision } = teamAbDivisions.find( + ({ tournamentTeamId }) => tournamentTeamId === team.id, + )!; + + return ( + + + + + ); + })} + +
TeamDivision
{team.name} + + {AB_DIVISION_RADIO_OPTIONS.map(({ value, label }) => ( + { + const newDivision: AbDivisionValue = + rawValue === "unassigned" + ? null + : (Number(rawValue) as 0 | 1); + setTeamAbDivisions((teamAbDivisions) => + teamAbDivisions.map((t) => + t.tournamentTeamId === team.id + ? { ...t, abDivision: newDivision } + : t, + ), + ); + }} + > + {label} + + ))} + +
+ + Save + + + +
+ ); +} + function SeedAlert({ teamOrder }: { teamOrder: number[] }) { const tournament = useTournament(); const fetcher = useFetcher(); diff --git a/app/features/tournament/tournament-constants.ts b/app/features/tournament/tournament-constants.ts index cad0b1ab0..00169d2ec 100644 --- a/app/features/tournament/tournament-constants.ts +++ b/app/features/tournament/tournament-constants.ts @@ -13,6 +13,8 @@ export const TOURNAMENT = { BRACKET_NAME_MAX_LENGTH: 32, // just a fallback, normally this should be set by user explicitly RR_DEFAULT_TEAM_COUNT_PER_GROUP: 4, + RR_TEAMS_PER_GROUP_OPTIONS: [3, 4, 5, 6], + RR_AB_DIVISIONS_TEAMS_PER_GROUP_OPTIONS: [4, 6, 8, 10, 12], SWISS_DEFAULT_GROUP_COUNT: 1, SWISS_DEFAULT_ROUND_COUNT: 5, SE_DEFAULT_HAS_THIRD_PLACE_MATCH: true, diff --git a/app/features/tournament/tournament-schemas.server.ts b/app/features/tournament/tournament-schemas.server.ts index 09c4b4f71..92abdff25 100644 --- a/app/features/tournament/tournament-schemas.server.ts +++ b/app/features/tournament/tournament-schemas.server.ts @@ -77,6 +77,18 @@ export const seedsActionSchema = z.union([ ), ), }), + z.object({ + _action: _action("UPDATE_AB_DIVISIONS"), + abDivisions: z.preprocess( + safeJSONParse, + z.array( + z.object({ + tournamentTeamId: id, + abDivision: z.union([z.literal(0), z.literal(1), z.null()]), + }), + ), + ), + }), ]); export const tournamentSearchSearchParamsSchema = z.object({ diff --git a/app/modules/brackets-manager/create.ts b/app/modules/brackets-manager/create.ts index 56070db93..573d18aea 100644 --- a/app/modules/brackets-manager/create.ts +++ b/app/modules/brackets-manager/create.ts @@ -100,6 +100,8 @@ export class Create { * Group count must be given. It will distribute participants in groups and rounds. */ private roundRobin(): Stage { + if (this.stage.settings?.hasAbDivisions) return this.abDivisionRoundRobin(); + const groups = this.getRoundRobinGroups(); const stage = this.createStage(); @@ -109,6 +111,27 @@ export class Create { return stage; } + /** + * Creates a bipartite (A/B divisions) round-robin stage. + * + * Participants are partitioned into two pools by `abDivisions` (parallel to the seeding). + * Each group receives equal A and B teams, and matches only pair A against B. + */ + private abDivisionRoundRobin(): Stage { + const groups = this.getAbDivisionGroups(); + const stage = this.createStage(); + + for (let i = 0; i < groups.length; i++) + this.createAbDivisionRoundRobinGroup( + stage.id, + i + 1, + groups[i].a, + groups[i].b, + ); + + return stage; + } + /** * Creates a single elimination stage. * @@ -238,6 +261,33 @@ export class Create { this.createRound(stageId, groupId, i + 1, rounds[0].length, rounds[i]); } + /** + * Creates a bipartite round-robin group where every A team plays every B team exactly once. + * + * @param stageId ID of the parent stage. + * @param number Number in the stage. + * @param slotsA Slots in division A (ordered by seed). + * @param slotsB Slots in division B (ordered by seed). + */ + private createAbDivisionRoundRobinGroup( + stageId: number, + number: number, + slotsA: ParticipantSlot[], + slotsB: ParticipantSlot[], + ): void { + const groupId = this.insertGroup({ + stage_id: stageId, + number, + }); + + if (groupId === -1) throw Error("Could not insert the group."); + + const rounds = helpers.makeAbDivisionRoundRobinMatches(slotsA, slotsB); + + for (let i = 0; i < rounds.length; i++) + this.createRound(stageId, groupId, i + 1, rounds[0].length, rounds[i]); + } + /** * Creates a standard bracket, which is the only one in single elimination and the upper one in double elimination. * @@ -680,6 +730,58 @@ export class Create { return helpers.makeGroups(ordered, this.stage.settings.groupCount); } + /** + * Partitions the seeded slots into A and B pools then distributes them into groups + * such that each group has an equal number of A and B participants. + */ + private getAbDivisionGroups(): { + a: ParticipantSlot[]; + b: ParticipantSlot[]; + }[] { + if ( + this.stage.settings?.groupCount === undefined || + !Number.isInteger(this.stage.settings.groupCount) + ) + throw Error("You must specify a group count for round-robin stages."); + + if (this.stage.settings.groupCount <= 0) + throw Error("You must provide a strictly positive group count."); + + const abDivisions = this.stage.abDivisions; + if (!abDivisions) + throw Error( + "abDivisions must be provided when hasAbDivisions is enabled.", + ); + + const slots = this.getSlots(); + + if (abDivisions.length !== slots.length) + throw Error("abDivisions length must match the seeding length."); + + const divisionA: ParticipantSlot[] = []; + const divisionB: ParticipantSlot[] = []; + + for (let i = 0; i < slots.length; i++) { + const slot = slots[i]; + if (slot === null) + throw Error("BYEs are not supported with A/B divisions."); + + const division = abDivisions[i]; + if (division === 0) divisionA.push(slot); + else if (division === 1) divisionB.push(slot); + else + throw Error( + `Participant at seed ${i + 1} is missing an A/B division assignment.`, + ); + } + + return helpers.makeAbDivisionGroups( + divisionA, + divisionB, + this.stage.settings.groupCount, + ); + } + /** * Returns the ordering method for the groups in a round-robin stage. */ diff --git a/app/modules/brackets-manager/helpers.ts b/app/modules/brackets-manager/helpers.ts index ee00d77ed..f77801cbd 100644 --- a/app/modules/brackets-manager/helpers.ts +++ b/app/modules/brackets-manager/helpers.ts @@ -56,6 +56,99 @@ export function makeRoundRobinMatches( return [...distribution, ...symmetry]; } +/** + * Makes a list of rounds containing the matches of a bipartite (A/B divisions) round-robin group. + * + * Every A team plays every B team exactly once; there are no A-vs-A or B-vs-B matches. + * Round 1 is cross-seeded (strongest A vs weakest B), and B is rotated cyclically downward + * in each subsequent round. + * + * When the divisions have different sizes, the shorter side is padded with bye slots so the + * rotation still works. Those bye pairings are filtered out of the output, so each round has + * exactly `min(|A|, |B|)` real matches and the total is `|A| * |B|`. + * + * @param divisionA Participants in division A, ordered by seed. + * @param divisionB Participants in division B, ordered by seed. + */ +export function makeAbDivisionRoundRobinMatches( + divisionA: T[], + divisionB: T[], +): [T, T][][] { + const n = Math.max(divisionA.length, divisionB.length); + const paddedA: (T | null)[] = [ + ...divisionA, + ...Array(n - divisionA.length).fill(null), + ]; + const paddedB: (T | null)[] = [ + ...divisionB, + ...Array(n - divisionB.length).fill(null), + ]; + const rounds: [T, T][][] = []; + + for (let roundIdx = 0; roundIdx < n; roundIdx++) { + const matches: [T, T][] = []; + + for (let i = 0; i < n; i++) { + const bIdx = (((n - 1 - i - roundIdx) % n) + n) % n; + const a = paddedA[i]; + const b = paddedB[bIdx]; + if (a === null || b === null) continue; + matches.push([a, b]); + } + + rounds.push(matches); + } + + return rounds; +} + +/** + * Distributes A/B division participants into groups such that each group has an + * equal number of A and B participants. + * + * The snake ordering used by `groups.seed_optimized` is applied independently to + * each pool, so that relative seed order within each pool is preserved within + * every group. + * + * @param divisionA Participants in division A, ordered by seed. + * @param divisionB Participants in division B, ordered by seed. + * @param groupCount Number of groups to distribute into. + */ +export function makeAbDivisionGroups( + divisionA: T[], + divisionB: T[], + groupCount: number, +): { a: T[]; b: T[] }[] { + if (groupCount <= 0) throw Error("Group count must be strictly positive."); + + if (divisionA.length !== divisionB.length) { + if (groupCount !== 1) + throw Error( + "Uneven A/B divisions are only supported with a single group.", + ); + + return [{ a: divisionA, b: divisionB }]; + } + + if (divisionA.length % groupCount !== 0) + throw Error("Pool size must be divisible by group count."); + + const aOrdered = ordering["groups.seed_optimized"](divisionA, groupCount); + const bOrdered = ordering["groups.seed_optimized"](divisionB, groupCount); + + const perPoolGroupSize = divisionA.length / groupCount; + const groups: { a: T[]; b: T[] }[] = []; + + for (let i = 0; i < groupCount; i++) { + groups.push({ + a: aOrdered.slice(i * perPoolGroupSize, (i + 1) * perPoolGroupSize), + b: bOrdered.slice(i * perPoolGroupSize, (i + 1) * perPoolGroupSize), + }); + } + + return groups; +} + /** * Distributes participants in rounds for a round-robin group. * @@ -143,6 +236,57 @@ export function assertRoundRobin( } } +/** + * A helper to assert our generated bipartite round-robin is correct. + * + * @param divisionA Seeds in division A (ordered by seed). + * @param divisionB Seeds in division B (ordered by seed). + * @param output The resulting rounds of matches. + */ +export function assertAbDivisionRoundRobin( + divisionA: number[], + divisionB: number[], + output: [number, number][][], +): void { + const roundCount = Math.max(divisionA.length, divisionB.length); + const matchesPerRound = Math.min(divisionA.length, divisionB.length); + + if (output.length !== roundCount) throw Error("Round count is wrong"); + if (!output.every((round) => round.length === matchesPerRound)) + throw Error("Not every round has the good number of matches"); + + const aSet = new Set(divisionA); + const bSet = new Set(divisionB); + const seenPairings = new Set(); + + for (const round of output) { + const playingInRound = new Set(); + + for (const match of round) { + if (match.length !== 2) throw Error("One match is not a pair"); + + const [a, b] = match; + + if (!aSet.has(a)) throw Error(`${a} is not a division A participant`); + if (!bSet.has(b)) throw Error(`${b} is not a division B participant`); + + if (playingInRound.has(a)) throw Error("This team is already playing"); + playingInRound.add(a); + + if (playingInRound.has(b)) throw Error("This team is already playing"); + playingInRound.add(b); + + const pairingKey = `${a}-${b}`; + if (seenPairings.has(pairingKey)) + throw Error("The teams have already been paired"); + seenPairings.add(pairingKey); + } + } + + if (seenPairings.size !== divisionA.length * divisionB.length) + throw Error("Not every A vs B pairing was generated"); +} + /** * Distributes elements in groups of equal size. * diff --git a/app/modules/brackets-manager/test/helpers.test.ts b/app/modules/brackets-manager/test/helpers.test.ts index d119d0832..9a8de5d86 100644 --- a/app/modules/brackets-manager/test/helpers.test.ts +++ b/app/modules/brackets-manager/test/helpers.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test } from "vitest"; import { + assertAbDivisionRoundRobin, assertRoundRobin, balanceByes, + makeAbDivisionGroups, + makeAbDivisionRoundRobinMatches, makeGroups, makeRoundRobinMatches, } from "../helpers"; @@ -35,6 +38,256 @@ describe("Round-robin groups", () => { }); }); +describe("A/B divisions round-robin groups", () => { + test("should pair every A with every B exactly once for N=2..6", () => { + for (const n of [2, 3, 4, 5, 6]) { + const divisionA = Array.from({ length: n }, (_, i) => i + 1); + const divisionB = Array.from({ length: n }, (_, i) => i + 1 + n); + + assertAbDivisionRoundRobin( + divisionA, + divisionB, + makeAbDivisionRoundRobinMatches(divisionA, divisionB), + ); + } + }); + + test("should produce N rounds and N^2 matches total", () => { + for (const n of [2, 3, 4, 5, 6]) { + const divisionA = Array.from({ length: n }, (_, i) => i + 1); + const divisionB = Array.from({ length: n }, (_, i) => i + 1 + n); + + const rounds = makeAbDivisionRoundRobinMatches(divisionA, divisionB); + + expect(rounds).toHaveLength(n); + expect(rounds.flat()).toHaveLength(n * n); + expect(rounds.every((round) => round.length === n)).toBe(true); + } + }); + + test("round 1 is cross-seeded (A[i] vs B[N-1-i])", () => { + for (const n of [2, 3, 4, 5, 6]) { + const divisionA = Array.from({ length: n }, (_, i) => i + 1); + const divisionB = Array.from({ length: n }, (_, i) => i + 1 + n); + + const [firstRound] = makeAbDivisionRoundRobinMatches( + divisionA, + divisionB, + ); + + const expected = divisionA.map<[number, number]>((a, i) => [ + a, + divisionB[n - 1 - i], + ]); + expect(firstRound).toEqual(expected); + } + }); + + test("matches the spec example for N=6", () => { + const divisionA = [1, 2, 3, 4, 5, 6]; + const divisionB = [11, 12, 13, 14, 15, 16]; + + const rounds = makeAbDivisionRoundRobinMatches(divisionA, divisionB); + + expect(rounds[0]).toEqual([ + [1, 16], + [2, 15], + [3, 14], + [4, 13], + [5, 12], + [6, 11], + ]); + expect(rounds[1]).toEqual([ + [1, 15], + [2, 14], + [3, 13], + [4, 12], + [5, 11], + [6, 16], + ]); + expect(rounds[2]).toEqual([ + [1, 14], + [2, 13], + [3, 12], + [4, 11], + [5, 16], + [6, 15], + ]); + }); + + test("supports uneven divisions where |A| = |B| + 1", () => { + const divisionA = [1, 2, 3, 4, 5, 6]; + const divisionB = [11, 12, 13, 14, 15]; + + const rounds = makeAbDivisionRoundRobinMatches(divisionA, divisionB); + + assertAbDivisionRoundRobin(divisionA, divisionB, rounds); + expect(rounds).toHaveLength(6); + expect(rounds.flat()).toHaveLength(5 * 6); + expect(rounds.every((round) => round.length === 5)).toBe(true); + + const byeCountPerA = new Map(divisionA.map((a) => [a, 0])); + for (const round of rounds) { + const playingA = new Set(round.map(([a]) => a)); + for (const a of divisionA) { + if (!playingA.has(a)) byeCountPerA.set(a, byeCountPerA.get(a)! + 1); + } + } + expect([...byeCountPerA.values()]).toEqual([1, 1, 1, 1, 1, 1]); + }); + + test("supports uneven divisions where |B| = |A| + 1", () => { + const divisionA = [1, 2, 3, 4, 5]; + const divisionB = [11, 12, 13, 14, 15, 16]; + + const rounds = makeAbDivisionRoundRobinMatches(divisionA, divisionB); + + assertAbDivisionRoundRobin(divisionA, divisionB, rounds); + expect(rounds).toHaveLength(6); + expect(rounds.flat()).toHaveLength(5 * 6); + expect(rounds.every((round) => round.length === 5)).toBe(true); + + const byeCountPerB = new Map(divisionB.map((b) => [b, 0])); + for (const round of rounds) { + const playingB = new Set(round.map(([, b]) => b)); + for (const b of divisionB) { + if (!playingB.has(b)) byeCountPerB.set(b, byeCountPerB.get(b)! + 1); + } + } + expect([...byeCountPerB.values()]).toEqual([1, 1, 1, 1, 1, 1]); + }); + + test("handles non-contiguous seed identifiers in each pool", () => { + const divisionA = [1, 4, 5]; + const divisionB = [9, 10, 12]; + + const rounds = makeAbDivisionRoundRobinMatches(divisionA, divisionB); + + assertAbDivisionRoundRobin(divisionA, divisionB, rounds); + expect(rounds[0]).toEqual([ + [1, 12], + [4, 10], + [5, 9], + ]); + }); +}); + +describe("A/B division group distribution", () => { + const CASES: ReadonlyArray = [ + [6, 1], + [6, 2], + [6, 3], + [6, 6], + [4, 1], + [4, 2], + [4, 4], + [3, 1], + [3, 3], + [8, 2], + [8, 4], + ]; + + test("places all teams with equal A/B per group for supported sizes", () => { + for (const [poolSize, groupCount] of CASES) { + const divisionA = Array.from({ length: poolSize }, (_, i) => i + 1); + const divisionB = Array.from( + { length: poolSize }, + (_, i) => i + 1 + poolSize, + ); + + const groups = makeAbDivisionGroups(divisionA, divisionB, groupCount); + + expect(groups).toHaveLength(groupCount); + + const perGroupSize = poolSize / groupCount; + for (const group of groups) { + expect(group.a).toHaveLength(perGroupSize); + expect(group.b).toHaveLength(perGroupSize); + } + + const flatA = groups.flatMap((g) => g.a).sort((x, y) => x - y); + const flatB = groups.flatMap((g) => g.b).sort((x, y) => x - y); + expect(flatA).toEqual(divisionA); + expect(flatB).toEqual(divisionB); + } + }); + + test("preserves ascending seed order within each group's A and B pools", () => { + for (const [poolSize, groupCount] of CASES) { + const divisionA = Array.from({ length: poolSize }, (_, i) => i + 1); + const divisionB = Array.from( + { length: poolSize }, + (_, i) => i + 1 + poolSize, + ); + + const groups = makeAbDivisionGroups(divisionA, divisionB, groupCount); + + for (const group of groups) { + expect(group.a).toEqual([...group.a].sort((x, y) => x - y)); + expect(group.b).toEqual([...group.b].sort((x, y) => x - y)); + } + } + }); + + test("is deterministic for identical input", () => { + const divisionA = [1, 2, 3, 4, 5, 6]; + const divisionB = [7, 8, 9, 10, 11, 12]; + + const first = makeAbDivisionGroups(divisionA, divisionB, 2); + const second = makeAbDivisionGroups(divisionA, divisionB, 2); + + expect(first).toEqual(second); + }); + + test("matches the expected snake distribution for 12 teams, 2 groups", () => { + const divisionA = [1, 2, 3, 4, 5, 6]; + const divisionB = [7, 8, 9, 10, 11, 12]; + + expect(makeAbDivisionGroups(divisionA, divisionB, 2)).toEqual([ + { a: [1, 4, 5], b: [7, 10, 11] }, + { a: [2, 3, 6], b: [8, 9, 12] }, + ]); + }); + + test("matches the expected snake distribution for 12 teams, 3 groups", () => { + const divisionA = [1, 2, 3, 4, 5, 6]; + const divisionB = [7, 8, 9, 10, 11, 12]; + + expect(makeAbDivisionGroups(divisionA, divisionB, 3)).toEqual([ + { a: [1, 6], b: [7, 12] }, + { a: [2, 5], b: [8, 11] }, + { a: [3, 4], b: [9, 10] }, + ]); + }); + + test("single group contains all teams", () => { + const divisionA = [1, 2, 3, 4]; + const divisionB = [5, 6, 7, 8]; + + expect(makeAbDivisionGroups(divisionA, divisionB, 1)).toEqual([ + { a: divisionA, b: divisionB }, + ]); + }); + + test("allows uneven pools with a single group", () => { + expect(makeAbDivisionGroups([1, 2, 3], [4, 5], 1)).toEqual([ + { a: [1, 2, 3], b: [4, 5] }, + ]); + }); + + test("throws when pools have different sizes and multiple groups", () => { + expect(() => makeAbDivisionGroups([1, 2, 3], [4, 5], 2)).toThrow(); + }); + + test("throws when pool size is not divisible by group count", () => { + expect(() => makeAbDivisionGroups([1, 2, 3], [4, 5, 6], 2)).toThrow(); + }); + + test("throws when group count is not positive", () => { + expect(() => makeAbDivisionGroups([1], [2], 0)).toThrow(); + }); +}); + describe("Seed ordering methods", () => { test("should place 2 participants with inner-outer method", () => { const teams = [1, 2]; diff --git a/app/modules/brackets-manager/test/round-robin.test.ts b/app/modules/brackets-manager/test/round-robin.test.ts index f27e52938..879e56886 100644 --- a/app/modules/brackets-manager/test/round-robin.test.ts +++ b/app/modules/brackets-manager/test/round-robin.test.ts @@ -164,6 +164,98 @@ describe("Create a round-robin stage", () => { }), ).toThrow("You must provide a strictly positive group count."); }); + + test("creates an A/B divisions round-robin where every A team plays every B team once", () => { + const seeding = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + // alternate A (0) / B (1) so that seed order 1..12 gives A=[1,3,5,7,9,11], B=[2,4,6,8,10,12] + const abDivisions = seeding.map((_, i) => (i % 2 === 0 ? 0 : 1)); + + manager.create({ + name: "AB Example", + tournamentId: 0, + type: "round_robin", + seeding, + abDivisions: abDivisions as (0 | 1)[], + settings: { + groupCount: 1, + hasAbDivisions: true, + seedOrdering: ["groups.seed_optimized"], + }, + }); + + expect(storage.select("group")!.length).toBe(1); + expect(storage.select("round")!.length).toBe(6); + expect(storage.select("match")!.length).toBe(36); + + const divisionAIds = new Set([1, 3, 5, 7, 9, 11]); + const divisionBIds = new Set([2, 4, 6, 8, 10, 12]); + const pairings = new Set(); + + for (const match of storage.select("match")!) { + const aId: number = match.opponent1.id; + const bId: number = match.opponent2.id; + + expect(divisionAIds.has(aId)).toBe(true); + expect(divisionBIds.has(bId)).toBe(true); + + const key = `${aId}-${bId}`; + expect(pairings.has(key)).toBe(false); + pairings.add(key); + } + + expect(pairings.size).toBe(36); + }); + + test("throws when A/B divisions are requested but abDivisions is missing", () => { + expect(() => + manager.create({ + name: "Missing AB", + tournamentId: 0, + type: "round_robin", + seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], + settings: { + groupCount: 1, + hasAbDivisions: true, + }, + }), + ).toThrow("abDivisions must be provided when hasAbDivisions is enabled."); + }); + + test("creates an A/B divisions round-robin with uneven (±1) divisions and a single group", () => { + const seeding = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; + manager.create({ + name: "Uneven AB", + tournamentId: 0, + type: "round_robin", + seeding, + abDivisions: [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0], + settings: { + groupCount: 1, + hasAbDivisions: true, + seedOrdering: ["groups.seed_optimized"], + }, + }); + + expect(storage.select("group")!.length).toBe(1); + expect(storage.select("round")!.length).toBe(6); + expect(storage.select("match")!.length).toBe(30); + }); + + test("throws when A/B divisions are uneven with multiple groups", () => { + expect(() => + manager.create({ + name: "Uneven AB multi-group", + tournamentId: 0, + type: "round_robin", + seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + abDivisions: [0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0], + settings: { + groupCount: 2, + hasAbDivisions: true, + }, + }), + ).toThrow("Uneven A/B divisions are only supported with a single group."); + }); }); describe("Update scores in a round-robin stage", () => { diff --git a/app/modules/brackets-model/input.ts b/app/modules/brackets-model/input.ts index 2a2e3745f..6651eff3d 100644 --- a/app/modules/brackets-model/input.ts +++ b/app/modules/brackets-model/input.ts @@ -43,6 +43,12 @@ export interface InputStage { /** Contains participants or `null` for BYEs. */ seeding?: Seeding; + /** + * A/B division assignment parallel to `seeding`. `0` = A, `1` = B. + * Required when `settings.hasAbDivisions` is `true`. + */ + abDivisions?: (0 | 1)[]; + /** Contains optional settings specific to each stage type. */ settings?: StageSettings; } @@ -90,6 +96,13 @@ export interface StageSettings { */ roundRobinMode?: RoundRobinMode; + /** + * Whether to generate a bipartite round-robin where teams are split into two + * A/B divisions and every match pairs one A team with one B team. + * Only valid on round-robin stages. + */ + hasAbDivisions?: boolean; + /** * A list of seeds per group for a round-robin stage to be manually ordered. * diff --git a/db-test.sqlite3 b/db-test.sqlite3 index 206fb8b82..75ffffbc7 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/e2e/seeds/db-seed-AB_RR.sqlite3 b/e2e/seeds/db-seed-AB_RR.sqlite3 new file mode 100644 index 000000000..ef8a147d7 Binary files /dev/null and b/e2e/seeds/db-seed-AB_RR.sqlite3 differ diff --git a/e2e/seeds/db-seed-DEFAULT.sqlite3 b/e2e/seeds/db-seed-DEFAULT.sqlite3 index 9cfd870d4..84e45b86b 100644 Binary files a/e2e/seeds/db-seed-DEFAULT.sqlite3 and b/e2e/seeds/db-seed-DEFAULT.sqlite3 differ diff --git a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 index fd9b1cbae..cbf1fbcc4 100644 Binary files a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 and b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 index 6d47ec50f..e200e2f47 100644 Binary files a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 and b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 index 091f384a3..2aa80b740 100644 Binary files a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 and b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 index 555a64f6f..52c27d975 100644 Binary files a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 and b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 index 782dd37e5..211fbe48b 100644 Binary files a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 and b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 differ diff --git a/e2e/seeds/db-seed-REG_OPEN.sqlite3 b/e2e/seeds/db-seed-REG_OPEN.sqlite3 index a35250680..92388cb5f 100644 Binary files a/e2e/seeds/db-seed-REG_OPEN.sqlite3 and b/e2e/seeds/db-seed-REG_OPEN.sqlite3 differ diff --git a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 index 8797d21d7..fdaf1c892 100644 Binary files a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 and b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 differ diff --git a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 index cb3e70730..79ca1fe6b 100644 Binary files a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 and b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 differ diff --git a/e2e/tournament-ab-divisions.spec.ts b/e2e/tournament-ab-divisions.spec.ts new file mode 100644 index 000000000..130f27593 --- /dev/null +++ b/e2e/tournament-ab-divisions.spec.ts @@ -0,0 +1,68 @@ +import { + expect, + impersonate, + navigate, + seed, + submit, + test, +} from "~/utils/playwright"; +import { tournamentBracketsPage } from "~/utils/urls"; + +const AB_RR_TOURNAMENT_ID = 8; +const TEAMS_PER_DIVISION = 6; + +test.describe("Tournament A/B divisions", () => { + test("assigns 6A/6B, starts bracket, renders 36 matches across 6 rounds and two standings tables", async ({ + page, + }) => { + test.slow(); + + await seed(page, "AB_RR"); + await impersonate(page); + + await navigate({ + page, + url: `/to/${AB_RR_TOURNAMENT_ID}/seeds`, + }); + + await page.getByTestId("set-ab-divisions").click(); + + const divisionRadioGroups = page.getByTestId("ab-division-radio-group"); + await expect(divisionRadioGroups).toHaveCount(TEAMS_PER_DIVISION * 2); + + for (let i = 0; i < TEAMS_PER_DIVISION; i++) { + await divisionRadioGroups.nth(i).getByText("A", { exact: true }).click(); + } + for (let i = TEAMS_PER_DIVISION; i < TEAMS_PER_DIVISION * 2; i++) { + await divisionRadioGroups.nth(i).getByText("B", { exact: true }).click(); + } + + await submit(page, "set-ab-divisions-submit-button"); + + await navigate({ + page, + url: tournamentBracketsPage({ tournamentId: AB_RR_TOURNAMENT_ID }), + }); + + await page.getByTestId("finalize-bracket-button").click(); + await submit(page, "confirm-finalize-bracket-button"); + + await expect(page.getByTestId("brackets-viewer")).toBeVisible(); + + await expect(page.locator("[data-match-id]")).toHaveCount( + TEAMS_PER_DIVISION * TEAMS_PER_DIVISION, + ); + + for ( + let roundNumber = 1; + roundNumber <= TEAMS_PER_DIVISION; + roundNumber++ + ) { + await expect( + page.getByText(`Round ${roundNumber}`, { exact: true }).first(), + ).toBeVisible(); + } + + await expect(page.getByTestId("rr-standings-table")).toHaveCount(2); + }); +}); diff --git a/locales/da/tournament.json b/locales/da/tournament.json index b18afafad..a32c9c617 100644 --- a/locales/da/tournament.json +++ b/locales/da/tournament.json @@ -153,6 +153,9 @@ "progression.error.NO_SE_SOURCE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", + "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", + "progression.error.AB_DIVISIONS_NOT_STARTING": "", + "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/locales/de/tournament.json b/locales/de/tournament.json index 63a7c0bca..40ef0e573 100644 --- a/locales/de/tournament.json +++ b/locales/de/tournament.json @@ -153,6 +153,9 @@ "progression.error.NO_SE_SOURCE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", + "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", + "progression.error.AB_DIVISIONS_NOT_STARTING": "", + "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/locales/en/tournament.json b/locales/en/tournament.json index a0edf91ee..8e425c287 100644 --- a/locales/en/tournament.json +++ b/locales/en/tournament.json @@ -153,6 +153,9 @@ "progression.error.NO_SE_SOURCE": "Single elimination is not a valid source bracket", "progression.error.NO_DE_POSITIVE": "Double elimination is not valid for positive progression", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "Swiss bracket with early advance/elimination must lead to another bracket", + "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "A/B divisions can only be enabled on round robin brackets", + "progression.error.AB_DIVISIONS_NOT_STARTING": "A/B divisions can only be enabled on starting brackets", + "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "A/B divisions requires an even number of teams per group", "lfg.askCaptainToJoinQueue": "Ask your team's captain or a manager to join the queue", "customFlow.beforeSet": "Before set", "customFlow.afterMap": "After map", diff --git a/locales/es-ES/tournament.json b/locales/es-ES/tournament.json index 72710251f..b4fbffccf 100644 --- a/locales/es-ES/tournament.json +++ b/locales/es-ES/tournament.json @@ -155,6 +155,9 @@ "progression.error.NO_SE_SOURCE": "La eliminación simple no es un cuadro de origen válido", "progression.error.NO_DE_POSITIVE": "La eliminación doble no es válida para progresión positiva", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "El cuadro suizo con avance/eliminación anticipada debe llevar a otro cuadro", + "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", + "progression.error.AB_DIVISIONS_NOT_STARTING": "", + "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/locales/es-US/tournament.json b/locales/es-US/tournament.json index 3ba0494c6..7d4156dbf 100644 --- a/locales/es-US/tournament.json +++ b/locales/es-US/tournament.json @@ -155,6 +155,9 @@ "progression.error.NO_SE_SOURCE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", + "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", + "progression.error.AB_DIVISIONS_NOT_STARTING": "", + "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/locales/fr-CA/tournament.json b/locales/fr-CA/tournament.json index 12440c68d..d7a42f9d0 100644 --- a/locales/fr-CA/tournament.json +++ b/locales/fr-CA/tournament.json @@ -155,6 +155,9 @@ "progression.error.NO_SE_SOURCE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", + "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", + "progression.error.AB_DIVISIONS_NOT_STARTING": "", + "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/locales/fr-EU/tournament.json b/locales/fr-EU/tournament.json index 5fdc55e35..acaaa5ef7 100644 --- a/locales/fr-EU/tournament.json +++ b/locales/fr-EU/tournament.json @@ -155,6 +155,9 @@ "progression.error.NO_SE_SOURCE": "Single elimination is not a valid source bracket", "progression.error.NO_DE_POSITIVE": "Double elimination is not valid for positive progression", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", + "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", + "progression.error.AB_DIVISIONS_NOT_STARTING": "", + "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/locales/he/tournament.json b/locales/he/tournament.json index d89da162a..f2c4c9278 100644 --- a/locales/he/tournament.json +++ b/locales/he/tournament.json @@ -155,6 +155,9 @@ "progression.error.NO_SE_SOURCE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", + "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", + "progression.error.AB_DIVISIONS_NOT_STARTING": "", + "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/locales/it/tournament.json b/locales/it/tournament.json index 6d46c16a7..41fbead34 100644 --- a/locales/it/tournament.json +++ b/locales/it/tournament.json @@ -155,6 +155,9 @@ "progression.error.NO_SE_SOURCE": "Eliminazione singola non è un bracket sorgente valido", "progression.error.NO_DE_POSITIVE": "Doppia eliminazione non è valida per progressione positiva", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", + "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", + "progression.error.AB_DIVISIONS_NOT_STARTING": "", + "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/locales/ja/tournament.json b/locales/ja/tournament.json index dd0b47e6e..49fbfd7f9 100644 --- a/locales/ja/tournament.json +++ b/locales/ja/tournament.json @@ -149,6 +149,9 @@ "progression.error.NO_SE_SOURCE": "シングルエリ三ネーションは妥当なブラケットではないです", "progression.error.NO_DE_POSITIVE": "ダブルエリミネーションは普通の進行(前向き)では妥当ではないです", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", + "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", + "progression.error.AB_DIVISIONS_NOT_STARTING": "", + "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/locales/ko/tournament.json b/locales/ko/tournament.json index 9e311c0ff..a0d03e2e6 100644 --- a/locales/ko/tournament.json +++ b/locales/ko/tournament.json @@ -149,6 +149,9 @@ "progression.error.NO_SE_SOURCE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", + "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", + "progression.error.AB_DIVISIONS_NOT_STARTING": "", + "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/locales/nl/tournament.json b/locales/nl/tournament.json index 1894e4dab..9147a4b26 100644 --- a/locales/nl/tournament.json +++ b/locales/nl/tournament.json @@ -153,6 +153,9 @@ "progression.error.NO_SE_SOURCE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", + "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", + "progression.error.AB_DIVISIONS_NOT_STARTING": "", + "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/locales/pl/tournament.json b/locales/pl/tournament.json index 2228823ad..62af58676 100644 --- a/locales/pl/tournament.json +++ b/locales/pl/tournament.json @@ -157,6 +157,9 @@ "progression.error.NO_SE_SOURCE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", + "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", + "progression.error.AB_DIVISIONS_NOT_STARTING": "", + "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/locales/pt-BR/tournament.json b/locales/pt-BR/tournament.json index 9d449ffbb..cf5869250 100644 --- a/locales/pt-BR/tournament.json +++ b/locales/pt-BR/tournament.json @@ -155,6 +155,9 @@ "progression.error.NO_SE_SOURCE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", + "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", + "progression.error.AB_DIVISIONS_NOT_STARTING": "", + "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/locales/ru/tournament.json b/locales/ru/tournament.json index 0a3df698c..b4bad5b52 100644 --- a/locales/ru/tournament.json +++ b/locales/ru/tournament.json @@ -157,6 +157,9 @@ "progression.error.NO_SE_SOURCE": "Single elimination не валидная сетка-исток", "progression.error.NO_DE_POSITIVE": "Double elimination не валидно для позитивной прогрессии", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", + "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", + "progression.error.AB_DIVISIONS_NOT_STARTING": "", + "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/locales/zh/tournament.json b/locales/zh/tournament.json index 3fb65d3c5..71f639be3 100644 --- a/locales/zh/tournament.json +++ b/locales/zh/tournament.json @@ -149,6 +149,9 @@ "progression.error.NO_SE_SOURCE": "", "progression.error.NO_DE_POSITIVE": "", "progression.error.SWISS_EARLY_ADVANCE_NO_DESTINATION": "", + "progression.error.AB_DIVISIONS_NOT_ROUND_ROBIN": "", + "progression.error.AB_DIVISIONS_NOT_STARTING": "", + "progression.error.AB_DIVISIONS_ODD_TEAMS_PER_GROUP": "", "lfg.askCaptainToJoinQueue": "", "customFlow.beforeSet": "", "customFlow.afterMap": "", diff --git a/migrations/133-tournament-team-ab-division.js b/migrations/133-tournament-team-ab-division.js new file mode 100644 index 000000000..d4042ad02 --- /dev/null +++ b/migrations/133-tournament-team-ab-division.js @@ -0,0 +1,5 @@ +export function up(db) { + db.prepare( + /* sql */ `alter table "TournamentTeam" add column "abDivision" integer`, + ).run(); +}