A/B (bipartite) round robin variation (#2985)

This commit is contained in:
Kalle
2026-04-18 14:21:28 +03:00
committed by GitHub
parent 2750a610a9
commit 2ed02f757d
63 changed files with 2255 additions and 25 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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<DBBoolean>;
lfgNote: string | null;
chatCode: Generated<string | null>;
/** A/B division assignment for bipartite round robin brackets. `0` = A, `1` = B, `null` = unassigned. */
abDivision: number | null;
}
export interface TournamentTeamCheckIn {

View File

@@ -8,4 +8,5 @@ export const SEED_VARIATIONS = [
"NO_SQ_GROUPS",
"TEAM_MAP_PREFS",
"FINALIZED_BRACKET",
"AB_RR",
] as const;

View File

@@ -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(),

View File

@@ -318,10 +318,14 @@ function TournamentFormatBracketSelector({
id="teamsPerGroup"
disabled={bracket.disabled}
>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
{(bracket.settings.hasAbDivisions
? TOURNAMENT.RR_AB_DIVISIONS_TEAMS_PER_GROUP_OPTIONS
: TOURNAMENT.RR_TEAMS_PER_GROUP_OPTIONS
).map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
<FormMessage type="info">
Participants are distributed equally, so groups may have fewer
@@ -330,6 +334,44 @@ function TournamentFormatBracketSelector({
</div>
) : null}
{bracket.type === "round_robin" && !bracket.sources ? (
<div>
<Label htmlFor={createId("abDivisions")}>A/B divisions</Label>
<SendouSwitch
id={createId("abDivisions")}
isSelected={Boolean(bracket.settings.hasAbDivisions)}
onChange={(isSelected) => {
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}
/>
<FormMessage type="info">
Teams split into A and B pools; every A plays every B once
</FormMessage>
</div>
) : null}
{bracket.type === "swiss" ? (
<div>
<Label htmlFor="swissGroupCount">Groups count</Label>

View File

@@ -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,

View File

@@ -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 (
<div className="stack lg">
{aStandings.length > 0 ? (
<StandingsTable
bracket={bracket}
standings={aStandings}
destinationBracket={destinationBracket}
possibleDestinationBrackets={possibleDestinationBrackets}
canEditDestination={canEditDestination}
allMatchesFinished={allMatchesFinished}
/>
) : null}
{bStandings.length > 0 ? (
<StandingsTable
bracket={bracket}
standings={bStandings}
destinationBracket={destinationBracket}
possibleDestinationBrackets={possibleDestinationBrackets}
canEditDestination={canEditDestination}
allMatchesFinished={allMatchesFinished}
/>
) : null}
</div>
);
}
if (standings.length === 0) {
return null;
}
return (
<StandingsTable
bracket={bracket}
standings={standings}
destinationBracket={destinationBracket}
possibleDestinationBrackets={possibleDestinationBrackets}
canEditDestination={canEditDestination}
allMatchesFinished={allMatchesFinished}
/>
);
}
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 (
<table className={styles.rrPlacementsTable} cellSpacing={0}>
<table
className={styles.rrPlacementsTable}
cellSpacing={0}
data-testid="rr-standings-table"
>
<thead>
<tr>
<th>Team</th>
@@ -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(

View File

@@ -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 });
});
});

View File

@@ -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 };
}

View File

@@ -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<any>("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<string, number> = {
"1-2": 1,
"1-4": 1,
"2-3": 2,
"3-4": 3,
};
for (const match of storage.select<any>("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]);
});
});

View File

@@ -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,

View File

@@ -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();
}

View File

@@ -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", () => {

View File

@@ -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) {

View File

@@ -556,6 +556,7 @@ export class Tournament {
return {
groupCount: Math.ceil(participantsCount / teamsPerGroup),
seedOrdering: ["groups.seed_optimized"],
hasAbDivisions: selectedSettings?.hasAbDivisions ?? false,
};
}
case "swiss": {

View File

@@ -33,6 +33,7 @@ describe("tournamentSummary()", () => {
inviteCode: null,
avgSeedingSkillOrdinal: null,
startingBracketIdx: null,
abDivision: null,
mapPool: [],
members: userIds.map((userId) => ({
country: null,

View File

@@ -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",

View File

@@ -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: [],

View File

@@ -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: [
{

View File

@@ -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,

View File

@@ -15,6 +15,7 @@ export const tournamentCtxTeam = (
inviteCode: null,
avgSeedingSkillOrdinal: null,
startingBracketIdx: null,
abDivision: null,
team: null,
mapPool: [],
members: [],

View File

@@ -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 (
<div>
<Outlet context={ctx} />
{bracket.preview &&
tournament.isOrganizer(user) &&
tournament.regularCheckInHasEnded &&
abDivisionsStartError ? (
<div className="stack items-center mb-4">
<Alert variation="WARNING">
<div data-testid="ab-divisions-imbalance-alert">
{abDivisionsStartError}
</div>
</Alert>
</div>
) : null}
{bracket.preview &&
bracket.enoughTeams &&
tournament.isOrganizer(user) &&
tournament.regularCheckInStartInThePast ? (
@@ -201,7 +217,11 @@ export default function TournamentBracketsPage() {
tournament.isDraft ? (
<DraftBracketStartPopover />
) : (
<BracketStarter bracket={bracket} bracketIdx={bracketIdx} />
<BracketStarter
bracket={bracket}
bracketIdx={bracketIdx}
isDisabled={Boolean(abDivisionsStartError)}
/>
)
) : null}
</Alert>
@@ -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
</SendouButton>

View File

@@ -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"),

View File

@@ -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.

View File

@@ -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);

View File

@@ -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) ? (
<>
<AbDivisionsDialog
key={tournament.ctx.teams
.map((team) => team.abDivision ?? -1)
.join()}
/>
<AbDivisionImbalanceWarning />
</>
) : null}
<ul className={styles.teamsList}>
<li className={styles.headerRow}>
<div />
@@ -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 (
<Alert variation="WARNING">
<div
data-testid="ab-divisions-imbalance-warning"
className="stack xs text-xs"
>
{warnings.map((warning) => (
<div key={warning}>{warning}</div>
))}
</div>
</Alert>
);
}
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 (
<div>
<SendouButton
size="small"
onPress={() => setIsOpen(true)}
data-testid="set-ab-divisions"
>
Set A/B divisions
</SendouButton>
<SendouDialog
heading="Setting A/B divisions"
isOpen={isOpen}
onClose={() => setIsOpen(false)}
isFullScreen
>
<fetcher.Form className="stack lg items-center" method="post">
<div className="stack horizontal sm text-xs">
<span>A: {counts.a}</span>
<span>B: {counts.b}</span>
<span>Unassigned: {counts.unassigned}</span>
</div>
<input type="hidden" name="_action" value="UPDATE_AB_DIVISIONS" />
<input
type="hidden"
name="abDivisions"
value={JSON.stringify(teamAbDivisions)}
/>
<Table>
<thead>
<tr>
<th>Team</th>
<th>Division</th>
</tr>
</thead>
<tbody>
{tournament.ctx.teams.map((team) => {
const { abDivision } = teamAbDivisions.find(
({ tournamentTeamId }) => tournamentTeamId === team.id,
)!;
return (
<tr key={team.id}>
<td>{team.name}</td>
<td data-testid="ab-division-radio-group">
<SendouChipRadioGroup>
{AB_DIVISION_RADIO_OPTIONS.map(({ value, label }) => (
<SendouChipRadio
key={value}
name={`ab-division-${team.id}`}
value={value}
checked={
(abDivision === null
? "unassigned"
: String(abDivision)) === value
}
onChange={(rawValue) => {
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}
</SendouChipRadio>
))}
</SendouChipRadioGroup>
</td>
</tr>
);
})}
</tbody>
</Table>
<SubmitButton
state={fetcher.state}
_action="UPDATE_AB_DIVISIONS"
size="big"
testId="set-ab-divisions-submit-button"
>
Save
</SubmitButton>
</fetcher.Form>
</SendouDialog>
</div>
);
}
function SeedAlert({ teamOrder }: { teamOrder: number[] }) {
const tournament = useTournament();
const fetcher = useFetcher();

View File

@@ -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,

View File

@@ -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({

View File

@@ -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.
*/

View File

@@ -56,6 +56,99 @@ export function makeRoundRobinMatches<T>(
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<T>(
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<T>(
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<string>();
for (const round of output) {
const playingInRound = new Set<number>();
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.
*

View File

@@ -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<readonly [number, number]> = [
[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];

View File

@@ -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<string>();
for (const match of storage.select<any>("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", () => {

View File

@@ -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.
*

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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);
});
});

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -0,0 +1,5 @@
export function up(db) {
db.prepare(
/* sql */ `alter table "TournamentTeam" add column "abDivision" integer`,
).run();
}