diff --git a/app/features/bracket-test/routes/bracket-test.tsx b/app/features/bracket-test/routes/bracket-test.tsx index 200181aaa..c610b1291 100644 --- a/app/features/bracket-test/routes/bracket-test.tsx +++ b/app/features/bracket-test/routes/bracket-test.tsx @@ -285,21 +285,16 @@ function generateBracketData( name: "Test Bracket", type: "swiss", seeding: teamIds, - settings: { - swiss: { groupCount: 1, roundCount: 5 }, - }, + settings: { groupCount: 1, roundCount: 5 }, }); } const settings = format === "single_elimination" - ? { consolationFinal: false } + ? { thirdPlaceMatch: false } : format === "double_elimination" - ? {} - : { - groupCount: Math.ceil(teamIds.length / 4), - seedOrdering: ["groups.seed_optimized" as const], - }; + ? null + : { teamsPerGroup: 4 }; return Engine.create({ tournamentId: 1, diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts index 58b08cc45..2017acafd 100644 --- a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts +++ b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts @@ -68,13 +68,13 @@ export const action: ActionFunction = async ({ params, request }) => { const groupCount = new Set(bracket.data.round.map((r) => r.group_id)) .size; - const settings = tournament.bracketManagerSettings( - bracket.settings, - bracket.type, - seeding.length, - ); + const hasThirdPlaceMatch = Engine.hasThirdPlaceMatch({ + type: bracket.type, + settings: bracket.settings, + participantsCount: seeding.length, + }); - const maps = settings.consolationFinal + const maps = hasThirdPlaceMatch ? adjustLinkedRounds({ maps: data.maps, thirdPlaceMatchLinked: data.thirdPlaceMatchLinked, @@ -107,7 +107,8 @@ export const action: ActionFunction = async ({ params, request }) => { name: bracket.name, type: bracket.type, seeding, - settings, + settings: bracket.settings, + independentRounds: tournament.isLeagueDivision, abDivisions, }); @@ -219,11 +220,12 @@ export const action: ActionFunction = async ({ params, request }) => { "Bracket has started, preparing maps no longer possible", ); - const hasThirdPlaceMatch = tournament.bracketManagerSettings( - bracket.settings, - bracket.type, - data.eliminationTeamCount ?? (bracket.seeding ?? []).length, - ).consolationFinal; + const hasThirdPlaceMatch = Engine.hasThirdPlaceMatch({ + type: bracket.type, + settings: bracket.settings, + participantsCount: + data.eliminationTeamCount ?? (bracket.seeding ?? []).length, + }); await TournamentRepository.upsertPreparedMaps({ bracketIdx: data.bracketIdx, diff --git a/app/features/tournament-bracket/components/BracketMapListDialog.tsx b/app/features/tournament-bracket/components/BracketMapListDialog.tsx index 0a2467f6e..b346ac908 100644 --- a/app/features/tournament-bracket/components/BracketMapListDialog.tsx +++ b/app/features/tournament-bracket/components/BracketMapListDialog.tsx @@ -40,6 +40,7 @@ import { calendarEditPage } from "~/utils/urls"; import { SendouButton } from "../../../components/elements/Button"; import { logger } from "../../../utils/logger"; import type { Bracket } from "../core/Bracket"; +import * as Engine from "../core/engine"; import * as PreparedMaps from "../core/PreparedMaps"; import { getRounds } from "../core/rounds"; import type { Tournament } from "../core/Tournament"; @@ -99,11 +100,11 @@ export function BracketMapListDialog({ const [thirdPlaceMatchLinked, setThirdPlaceMatchLinked] = React.useState( () => { if ( - !tournament.bracketManagerSettings( - bracket.settings, - bracket.type, - eliminationTeamCount ?? 2, - ).consolationFinal + !Engine.hasThirdPlaceMatch({ + type: bracket.type, + settings: bracket.settings, + participantsCount: eliminationTeamCount ?? 2, + }) ) { return true; // default to true if not applicable or elimination team count not yet set (initial state) } diff --git a/app/features/tournament-bracket/core/Bracket.test.ts b/app/features/tournament-bracket/core/Bracket.test.ts index 23a5f8535..2f921df7d 100644 --- a/app/features/tournament-bracket/core/Bracket.test.ts +++ b/app/features/tournament-bracket/core/Bracket.test.ts @@ -103,10 +103,8 @@ describe("swiss standings - losses against tied", () => { type: "swiss", seeding: [1, 2, 3], settings: { - swiss: { - groupCount: 1, - roundCount: 5, - }, + groupCount: 1, + roundCount: 5, }, }); @@ -241,7 +239,6 @@ describe("round robin standings - dropped out teams", () => { seeding: [1, 2, 3, 4], settings: { groupCount: 1, - seedOrdering: ["groups.seed_optimized"], }, }); @@ -418,7 +415,6 @@ describe("round robin A/B divisions standings", () => { settings: { groupCount: 1, hasAbDivisions: true, - seedOrdering: ["groups.seed_optimized"], }, }); @@ -717,7 +713,7 @@ describe("double elimination standings - projected ties", () => { tournamentId: 1, type: "double_elimination", seeding: [1, 2, 3, 4, 5, 6, 7, 8], - settings: { seedOrdering: ["natural"] }, + settings: {}, }); const groupId = (number: number) => diff --git a/app/features/tournament-bracket/core/Bracket/Bracket.ts b/app/features/tournament-bracket/core/Bracket/Bracket.ts index 4835daeb9..0110f6af6 100644 --- a/app/features/tournament-bracket/core/Bracket/Bracket.ts +++ b/app/features/tournament-bracket/core/Bracket/Bracket.ts @@ -293,14 +293,12 @@ 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) + ? this.abDivisionsForPreview( + teams, + Engine.roundRobinGroupCount(this.settings, teams.length), + ) : undefined; return Engine.create({ @@ -309,11 +307,12 @@ export abstract class Bracket { type: this.type, seeding: teams, settings: abDivisions - ? settings + ? this.settings : { - ...settings, + ...this.settings, hasAbDivisions: false, }, + independentRounds: this.tournament.isLeagueDivision, abDivisions, }); } @@ -323,10 +322,8 @@ export abstract class Bracket { private abDivisionsForPreview( teams: number[], - groupCount: number | undefined, + groupCount: number, ): (0 | 1)[] | undefined { - if (!groupCount) return undefined; - const assignments = teams.map((teamId) => { const team = this.tournament.teamById(teamId); return team?.abDivision ?? null; diff --git a/app/features/tournament-bracket/core/Swiss.test.ts b/app/features/tournament-bracket/core/Swiss.test.ts index fd4f1cbde..87232662c 100644 --- a/app/features/tournament-bracket/core/Swiss.test.ts +++ b/app/features/tournament-bracket/core/Swiss.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import type { TournamentStageSettings } from "~/db/tables"; import { Tournament } from "~/features/tournament-bracket/core/Tournament"; import { LOW_INK_AUGUST_2025, @@ -17,12 +18,12 @@ const Swiss = { tournamentId: number; name: string; seeding: number[]; - settings?: Engine.StageSettings; + settings?: TournamentStageSettings; }) => Engine.create({ ...args, type: "swiss", - settings: args.settings ?? {}, + settings: args.settings ?? null, }), }; @@ -62,10 +63,8 @@ describe("Swiss", () => { const data = Swiss.create( createArgsWithDefaults({ settings: { - swiss: { - groupCount: 1, - roundCount: 4, - }, + groupCount: 1, + roundCount: 4, }, }), ); @@ -77,10 +76,8 @@ describe("Swiss", () => { const data = Swiss.create( createArgsWithDefaults({ settings: { - swiss: { - groupCount: 2, - roundCount: 5, - }, + groupCount: 2, + roundCount: 5, }, }), ); diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index 54450fdd7..71981f272 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -15,7 +15,6 @@ import { } from "~/features/tournament/tournament-utils"; import type { Match, - Stage, TournamentManagerDataSet, } from "~/features/tournament-bracket/core/engine/types"; import type * as Progression from "~/features/tournament-bracket/core/Progression"; @@ -347,11 +346,7 @@ export class Tournament { "round_robin" | "swiss" >, seeding: candidateTeams, - settings: this.bracketManagerSettings( - settings, - bracket.type, - candidateTeams.length, - ), + settings, }).match; const replays: [number, number][] = []; for (const match of matches) { @@ -483,60 +478,6 @@ export class Tournament { ); } - // xxx: could all of this be internal to Engine (except user input) - /** Provides settings for the brackets-manager module with our selected defaults */ - bracketManagerSettings( - selectedSettings: TournamentStageSettings | null, - type: Tables["TournamentStage"]["type"], - participantsCount: number, - ): Stage["settings"] { - switch (type) { - case "single_elimination": { - if (participantsCount < 4) { - return { consolationFinal: false }; - } - - return { - consolationFinal: - selectedSettings?.thirdPlaceMatch ?? - TOURNAMENT.SE_DEFAULT_HAS_THIRD_PLACE_MATCH, - }; - } - case "double_elimination": { - return {}; - } - case "round_robin": { - const teamsPerGroup = - selectedSettings?.teamsPerGroup ?? - TOURNAMENT.RR_DEFAULT_TEAM_COUNT_PER_GROUP; - - return { - groupCount: Math.ceil(participantsCount / teamsPerGroup), - seedOrdering: ["groups.seed_optimized"], - hasAbDivisions: selectedSettings?.hasAbDivisions ?? false, - ...(this.isLeagueDivision ? { independentRounds: true } : {}), - }; - } - case "swiss": { - return { - swiss: - selectedSettings?.groupCount && selectedSettings.roundCount - ? { - groupCount: selectedSettings.groupCount, - roundCount: selectedSettings.roundCount, - } - : { - groupCount: TOURNAMENT.SWISS_DEFAULT_GROUP_COUNT, - roundCount: TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT, - }, - }; - } - default: { - assertUnreachable(type); - } - } - } - /** Is tournament ranked (affects SP/Skill). For tournament to be ranked the organizer needs to enable it and it needs to fit the conditions e.g. it needs to happen when a ranked season is active. */ get ranked() { return tournamentIsRanked({ diff --git a/app/features/tournament-bracket/core/engine/create/builder.ts b/app/features/tournament-bracket/core/engine/create/builder.ts index 884b0e495..4770e9415 100644 --- a/app/features/tournament-bracket/core/engine/create/builder.ts +++ b/app/features/tournament-bracket/core/engine/create/builder.ts @@ -1,11 +1,11 @@ import * as helpers from "../helpers"; import type { BracketData, - CreateBracketInput, Duel, GroupData, MatchData, ParticipantSlot, + ResolvedCreateBracketInput, RoundData, Seeding, SeedOrdering, @@ -15,7 +15,6 @@ import type { } from "../types"; import { MatchStatus } from "../types"; import { - balanceByes, defaultMinorOrdering, ordering, padSeedingToPowerOfTwo, @@ -28,13 +27,12 @@ import { * being written to storage. */ export class StageCreator { - readonly input: CreateBracketInput; + readonly input: ResolvedCreateBracketInput; settings: StageSettings; seeding: Seeding | undefined; - readonly seedOrdering: SeedOrdering[]; readonly data: BracketData; - constructor(input: CreateBracketInput) { + constructor(input: ResolvedCreateBracketInput) { this.input = input; this.settings = structuredClone(input.settings) ?? {}; const seeding = input.seeding ? [...input.seeding] : undefined; @@ -42,7 +40,6 @@ export class StageCreator { seeding && input.type !== "round_robin" ? padSeedingToPowerOfTwo(seeding) : seeding; - this.seedOrdering = this.settings.seedOrdering || []; this.data = { stage: [], group: [], round: [], match: [] }; if (!input.name) throw Error("You must provide a name for the stage."); @@ -359,9 +356,6 @@ export class StageCreator { helpers.ensureNoDuplicates(this.seeding); this.seeding = helpers.fixSeeding(this.seeding, size); - if (this.input.type !== "round_robin" && this.settings.balanceByes) - this.seeding = balanceByes(this.seeding, this.settings.size); - return this.getSlotsUsingIds(this.seeding, positions); } @@ -389,69 +383,22 @@ export class StageCreator { return positions.map((position) => slots[position - 1]); } - /** - * Safely gets an ordering by its index in the stage input settings. - */ - getOrdering( - orderingIndex: number, - stageType: "elimination" | "groups", - defaultMethod: SeedOrdering, - ): SeedOrdering { - if (!this.settings.seedOrdering) { - this.seedOrdering.push(defaultMethod); - return defaultMethod; - } - - const method = this.settings.seedOrdering[orderingIndex]; - if (!method) { - this.seedOrdering.push(defaultMethod); - return defaultMethod; - } - - if (stageType === "elimination" && method.match(/^groups\./)) - throw Error( - "You must specify a seed ordering method without a 'groups' prefix", - ); - - if ( - stageType === "groups" && - method !== "natural" && - !method.match(/^groups\./) - ) - throw Error( - "You must specify a seed ordering method with a 'groups' prefix", - ); - - return method; - } - - /** - * Returns the ordering method for the groups in a round-robin stage. - */ - getRoundRobinOrdering(): SeedOrdering { - return this.getOrdering(0, "groups", "groups.effort_balanced"); - } - /** * Returns the ordering method for the first round of the upper bracket of an elimination stage. */ getStandardBracketFirstRoundOrdering(): SeedOrdering { - return this.getOrdering(0, "elimination", "space_between"); + return "space_between"; } /** - * Safely gets the only major ordering for the lower bracket. + * The only major ordering for the lower bracket. */ private getMajorOrdering(participantCount: number): SeedOrdering { - return this.getOrdering( - 1, - "elimination", - defaultMinorOrdering[participantCount]?.[0] || "natural", - ); + return defaultMinorOrdering[participantCount]?.[0] || "natural"; } /** - * Safely gets a minor ordering for the lower bracket by its index. + * A minor ordering for the lower bracket by its index. */ private getMinorOrdering( participantCount: number, @@ -461,11 +408,7 @@ export class StageCreator { // No ordering for the last minor round. There is only one participant to order. if (index === minorRoundCount - 1) return undefined; - return this.getOrdering( - 2 + index, - "elimination", - defaultMinorOrdering[participantCount]?.[1 + index] || "natural", - ); + return defaultMinorOrdering[participantCount]?.[1 + index] || "natural"; } /** @@ -485,18 +428,4 @@ export class StageCreator { return stage; } - - /** - * Ensures that the seed ordering list is stored even if it was not given in the first place. - */ - ensureSeedOrdering(): void { - if (this.settings.seedOrdering?.length === this.seedOrdering.length) return; - - const stage = this.data.stage[0]; - - stage.settings = { - ...stage.settings, - seedOrdering: this.seedOrdering, - }; - } } diff --git a/app/features/tournament-bracket/core/engine/create/double-elimination.test.ts b/app/features/tournament-bracket/core/engine/create/double-elimination.test.ts index ab2422c02..dd61070b6 100644 --- a/app/features/tournament-bracket/core/engine/create/double-elimination.test.ts +++ b/app/features/tournament-bracket/core/engine/create/double-elimination.test.ts @@ -14,7 +14,7 @@ describe("Create double elimination stage", () => { tournamentId: 0, type: "double_elimination", seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], - settings: { seedOrdering: ["natural"] }, + settings: {}, }); const stage = bracket.stage(); @@ -41,7 +41,7 @@ describe("Create double elimination stage", () => { tournamentId: 0, type: "double_elimination", seeding: [1, 2, 3, 4, 5, 6, 7, 8], - settings: { seedOrdering: ["natural"] }, + settings: {}, }); expect(bracket.groups().length).toBe(3); diff --git a/app/features/tournament-bracket/core/engine/create/double-elimination.ts b/app/features/tournament-bracket/core/engine/create/double-elimination.ts index b3727a270..531917320 100644 --- a/app/features/tournament-bracket/core/engine/create/double-elimination.ts +++ b/app/features/tournament-bracket/core/engine/create/double-elimination.ts @@ -10,12 +10,6 @@ import { ordering } from "./seeding"; * between the winner of both brackets. */ export function createDoubleElimination(creator: StageCreator): void { - if ( - Array.isArray(creator.settings.seedOrdering) && - creator.settings.seedOrdering.length < 1 - ) - throw Error("You must specify at least one seed ordering method."); - const slots = creator.getSlots(); const stage = creator.createStage(); const method = creator.getStandardBracketFirstRoundOrdering(); diff --git a/app/features/tournament-bracket/core/engine/create/index.ts b/app/features/tournament-bracket/core/engine/create/index.ts index 5ab41e306..24a533131 100644 --- a/app/features/tournament-bracket/core/engine/create/index.ts +++ b/app/features/tournament-bracket/core/engine/create/index.ts @@ -1,17 +1,41 @@ -import type { CreateBracketInput, CreatedBracket } from "../types"; +import type { + CreateBracketInput, + CreatedBracket, + ResolvedCreateBracketInput, +} from "../types"; import { StageCreator } from "./builder"; import { createDoubleElimination } from "./double-elimination"; import { createRoundRobin } from "./round-robin"; +import { resolveStageSettings } from "./settings"; import { createSingleElimination } from "./single-elimination"; import { createSwiss } from "./swiss"; /** - * Generates the full structure for a new bracket of any type. Pure function: - * returns rows with local ids (0..n-1 per table); the repository maps them to - * real row ids on insert. For swiss this includes the empty future rounds + - * round 1 matches. + * Generates the full structure for a new bracket of any type from the + * user-selected settings. Pure function: returns rows with local ids + * (0..n-1 per table); the repository maps them to real row ids on insert. + * For swiss this includes the empty future rounds + round 1 matches. */ export function create(input: CreateBracketInput): CreatedBracket { + return createResolved({ + tournamentId: input.tournamentId, + name: input.name, + type: input.type, + seeding: input.seeding, + settings: resolveStageSettings(input), + abDivisions: input.abDivisions, + number: input.number, + }); +} + +/** + * Engine-internal `create` taking already-resolved internal stage settings. + * Tests use this to control knobs that are an implementation detail to the + * app (seed ordering, byes balancing, TBD slots via `settings.size`). + */ +export function createResolved( + input: ResolvedCreateBracketInput, +): CreatedBracket { if (input.type === "swiss") return createSwiss(input); const creator = new StageCreator(input); @@ -30,8 +54,5 @@ export function create(input: CreateBracketInput): CreatedBracket { throw Error("Unknown stage type."); } - // xxx: hmm, what? - creator.ensureSeedOrdering(); - return creator.data; } diff --git a/app/features/tournament-bracket/core/engine/create/round-robin.test.ts b/app/features/tournament-bracket/core/engine/create/round-robin.test.ts index ebdc04d13..ae9edba2d 100644 --- a/app/features/tournament-bracket/core/engine/create/round-robin.test.ts +++ b/app/features/tournament-bracket/core/engine/create/round-robin.test.ts @@ -118,7 +118,7 @@ describe("Create a round-robin stage", () => { tournamentId: 0, type: "round_robin", seeding: [1, 2, 3, 4, 5], - settings: { groupCount: 2, seedOrdering: ["groups.seed_optimized"] }, + settings: { groupCount: 2 }, }); const isRealMatch = (match: { @@ -160,15 +160,14 @@ describe("Create a round-robin stage", () => { expect(bracket.matches().length).toBe(4 * 3 * 2); }); - test("should create a round-robin stage with effort balanced", () => { + test("should order the groups with snake seeding", () => { bracket.create({ - name: "Example with effort balanced", + name: "Example with snake seeding", tournamentId: 0, type: "round_robin", seeding: [1, 2, 3, 4, 5, 6, 7, 8], settings: { groupCount: 2, - seedOrdering: ["groups.seed_optimized"], }, }); @@ -195,7 +194,6 @@ describe("Create a round-robin stage", () => { settings: { groupCount: 0, size: 4, - seedOrdering: ["groups.seed_optimized"], }, }), ).toThrow("You must provide a strictly positive group count."); @@ -218,7 +216,6 @@ describe("Create a round-robin stage", () => { settings: { groupCount: 1, hasAbDivisions: true, - seedOrdering: ["groups.seed_optimized"], }, }); @@ -271,7 +268,6 @@ describe("Create a round-robin stage", () => { settings: { groupCount: 1, hasAbDivisions: true, - seedOrdering: ["groups.seed_optimized"], }, }); diff --git a/app/features/tournament-bracket/core/engine/create/round-robin.ts b/app/features/tournament-bracket/core/engine/create/round-robin.ts index e4d87a9ca..c5f7be1d4 100644 --- a/app/features/tournament-bracket/core/engine/create/round-robin.ts +++ b/app/features/tournament-bracket/core/engine/create/round-robin.ts @@ -65,15 +65,11 @@ function getRoundRobinGroups(creator: StageCreator): ParticipantSlot[][] { return helpers.makeGroups(slots, creator.settings.groupCount); } - if ( - Array.isArray(creator.settings.seedOrdering) && - creator.settings.seedOrdering.length !== 1 - ) - throw Error("You must specify one seed ordering method."); - - const method = creator.getRoundRobinOrdering(); const slots = creator.getSlots(); - const ordered = ordering[method](slots, creator.settings.groupCount); + const ordered = ordering["groups.seed_optimized"]( + slots, + creator.settings.groupCount, + ); return helpers.makeGroups(ordered, creator.settings.groupCount); } diff --git a/app/features/tournament-bracket/core/engine/create/seeding.ts b/app/features/tournament-bracket/core/engine/create/seeding.ts index fe7098a97..658f5418e 100644 --- a/app/features/tournament-bracket/core/engine/create/seeding.ts +++ b/app/features/tournament-bracket/core/engine/create/seeding.ts @@ -1,7 +1,5 @@ // https://web.archive.org/web/20200601102344/https://tl.net/forum/sc2-tournaments/202139-superior-double-elimination-losers-bracket-seeding -// xxx: some can probably be removed - import invariant from "~/utils/invariant"; import type { OrderingMap, Seeding, SeedOrdering } from "../types"; @@ -50,61 +48,6 @@ export const ordering: OrderingMap = { }); } }, - inner_outer: (array: T[]) => { - if (array.length === 2) return array; - - const size = array.length / 4; - - const innerPart = [ - array.slice(size, 2 * size), - array.slice(2 * size, 3 * size), - ]; // [_, X, X, _] - const outerPart = [array.slice(0, size), array.slice(3 * size, 4 * size)]; // [X, _, _, X] - - const methods = { - inner(part: T[][]): T[] { - return [part[0].pop()!, part[1].shift()!]; - }, - outer(part: T[][]): T[] { - return [part[0].shift()!, part[1].pop()!]; - }, - }; - - const result: T[] = []; - - /** - * Adds a part (inner or outer) of a part. - * - * @param part The part to process. - * @param method The method to use. - */ - function add(part: T[][], method: "inner" | "outer"): void { - if (part[0].length > 0 && part[1].length > 0) - result.push(...methods[method](part)); - } - - for (let i = 0; i < size / 2; i++) { - add(outerPart, "outer"); // Outer part's outer - add(innerPart, "inner"); // Inner part's inner - add(outerPart, "inner"); // Outer part's inner - add(innerPart, "outer"); // Inner part's outer - } - - return result; - }, - "groups.effort_balanced": (array: T[], groupCount: number) => { - const result: T[] = []; - let i = 0; - let j = 0; - - while (result.length < array.length) { - result.push(array[i]); - i += groupCount; - if (i >= array.length) i = ++j; - } - - return result; - }, "groups.seed_optimized": (array: T[], groupCount: number) => { const groups = Array.from(Array(groupCount), (_): T[] => []); @@ -120,9 +63,6 @@ export const ordering: OrderingMap = { return groups.flat(); }, - "groups.bracket_optimized": () => { - throw Error("Not implemented."); - }, }; export const defaultMinorOrdering: { [key: number]: SeedOrdering[] } = { @@ -143,38 +83,6 @@ export const defaultMinorOrdering: { [key: number]: SeedOrdering[] } = { ], }; -/** - * Balances BYEs to prevents having BYE against BYE in matches. - * - * @param seeding The seeding of the stage. - * @param participantCount The number of participants in the stage. - */ -export function balanceByes( - seeding: Seeding, - participantCount?: number, -): Seeding { - const nonNullSeeding = seeding.filter((v) => v !== null); - const size = participantCount || getNearestPowerOfTwo(nonNullSeeding.length); - - if (nonNullSeeding.length < size / 2) { - const flat = nonNullSeeding.flatMap((v) => [v, null]); - return setArraySize(flat, size, null); - } - - const nonNullCount = nonNullSeeding.length; - const nullCount = size - nonNullCount; - const againstEachOther = nonNullSeeding - .slice(0, nonNullCount - nullCount) - .filter((_, i) => i % 2 === 0) - .map((_, i) => [nonNullSeeding[2 * i], nonNullSeeding[2 * i + 1]]); - const againstNull = nonNullSeeding - .slice(nonNullCount - nullCount, nonNullCount) - .map((v) => [v, null]); - const flat = [...againstEachOther.flat(), ...againstNull.flat()]; - - return setArraySize(flat, size, null); -} - /** * Pads the seeding with BYEs (`null`) until its length is a power of two. * diff --git a/app/features/tournament-bracket/core/engine/create/settings.ts b/app/features/tournament-bracket/core/engine/create/settings.ts new file mode 100644 index 000000000..bd6116961 --- /dev/null +++ b/app/features/tournament-bracket/core/engine/create/settings.ts @@ -0,0 +1,77 @@ +import type { TournamentStageSettings } from "~/db/tables"; +import { TOURNAMENT } from "~/features/tournament/tournament-constants"; +import { assertUnreachable } from "~/utils/types"; +import type { CreateBracketInput, StageSettings, StageType } from "../types"; + +/** + * Resolves the user-selected settings into the engine's internal stage + * settings, applying our defaults (seed ordering, group counts etc.). + */ +export function resolveStageSettings(input: CreateBracketInput): StageSettings { + const { type, settings, seeding } = input; + + switch (type) { + case "single_elimination": { + return { + consolationFinal: hasThirdPlaceMatch({ + type, + settings, + participantsCount: seeding.length, + }), + }; + } + case "double_elimination": { + return {}; + } + case "round_robin": { + return { + groupCount: roundRobinGroupCount(settings, seeding.length), + hasAbDivisions: settings?.hasAbDivisions ?? false, + ...(input.independentRounds ? { independentRounds: true } : {}), + }; + } + case "swiss": { + return { + swiss: + settings?.groupCount && settings.roundCount + ? { + groupCount: settings.groupCount, + roundCount: settings.roundCount, + } + : { + groupCount: TOURNAMENT.SWISS_DEFAULT_GROUP_COUNT, + roundCount: TOURNAMENT.SWISS_DEFAULT_ROUND_COUNT, + }, + }; + } + default: { + assertUnreachable(type); + } + } +} + +/** Whether the bracket will include a third place match. Only possible for single elimination with at least 4 participants. */ +export function hasThirdPlaceMatch(args: { + type: StageType; + settings: TournamentStageSettings | null; + participantsCount: number; +}): boolean { + if (args.type !== "single_elimination") return false; + if (args.participantsCount < 4) return false; + + return ( + args.settings?.thirdPlaceMatch ?? + TOURNAMENT.SE_DEFAULT_HAS_THIRD_PLACE_MATCH + ); +} + +/** How many groups a round robin bracket will have, derived from the user-selected teams per group count and the participant count. */ +export function roundRobinGroupCount( + settings: TournamentStageSettings | null, + participantsCount: number, +): number { + const teamsPerGroup = + settings?.teamsPerGroup ?? TOURNAMENT.RR_DEFAULT_TEAM_COUNT_PER_GROUP; + + return Math.ceil(participantsCount / teamsPerGroup); +} diff --git a/app/features/tournament-bracket/core/engine/create/single-elimination.test.ts b/app/features/tournament-bracket/core/engine/create/single-elimination.test.ts index 57ad2db2e..f1108f5f8 100644 --- a/app/features/tournament-bracket/core/engine/create/single-elimination.test.ts +++ b/app/features/tournament-bracket/core/engine/create/single-elimination.test.ts @@ -14,7 +14,7 @@ describe("Create single elimination stage", () => { tournamentId: 0, type: "single_elimination" as const, seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], - settings: { seedOrdering: ["natural" as const] }, + settings: {}, }; bracket.create(example); @@ -34,13 +34,13 @@ describe("Create single elimination stage", () => { tournamentId: 0, type: "single_elimination", seeding: [1, null, 3, 4, null, null, 7, 8], - settings: { seedOrdering: ["natural"] }, + settings: {}, }); - expect(bracket.match(4).opponent1?.id).toBe(1); - expect(bracket.match(4).opponent2?.id).toBe(null); - expect(bracket.match(5).opponent1).toBe(null); - expect(bracket.match(5).opponent2?.id).toBe(null); + expect(bracket.match(4).opponent1?.id).toBe(null); + expect(bracket.match(4).opponent2?.id).toBe(4); + expect(bracket.match(5).opponent1?.id).toBe(7); + expect(bracket.match(5).opponent2?.id).toBe(3); }); test("should create a single elimination stage with consolation final", () => { @@ -49,7 +49,7 @@ describe("Create single elimination stage", () => { tournamentId: 0, type: "single_elimination", seeding: [1, 2, 3, 4, 5, 6, 7, 8], - settings: { consolationFinal: true, seedOrdering: ["natural"] }, + settings: { consolationFinal: true }, }); expect(bracket.groups().length).toBe(2); @@ -63,14 +63,14 @@ describe("Create single elimination stage", () => { tournamentId: 0, type: "single_elimination", seeding: [null, null, null, 4, 5, 6, 7, 8], - settings: { consolationFinal: true, seedOrdering: ["natural"] }, + settings: { consolationFinal: true }, }); - expect(bracket.match(4).opponent1).toBe(null); - expect(bracket.match(4).opponent2?.id).toBe(4); + expect(bracket.match(4).opponent1?.id).toBe(8); + expect(bracket.match(4).opponent2?.id).toBe(null); // Consolation final - expect(bracket.match(7).opponent1).toBe(null); + expect(bracket.match(7).opponent1?.id).toBe(null); expect(bracket.match(7).opponent2?.id).toBe(null); }); @@ -80,7 +80,7 @@ describe("Create single elimination stage", () => { tournamentId: 0, type: "single_elimination", seeding: [1, 2, 3, 4, 5, 6, 7, 8], - settings: { seedOrdering: ["natural"] }, + settings: {}, }); expect(bracket.groups().length).toBe(1); diff --git a/app/features/tournament-bracket/core/engine/create/single-elimination.ts b/app/features/tournament-bracket/core/engine/create/single-elimination.ts index b1cd98906..03f171e9b 100644 --- a/app/features/tournament-bracket/core/engine/create/single-elimination.ts +++ b/app/features/tournament-bracket/core/engine/create/single-elimination.ts @@ -8,12 +8,6 @@ import { ordering } from "./seeding"; * One bracket and optionally a consolation final between semi-final losers. */ export function createSingleElimination(creator: StageCreator): void { - if ( - Array.isArray(creator.settings.seedOrdering) && - creator.settings.seedOrdering.length !== 1 - ) - throw Error("You must specify one seed ordering method."); - const slots = creator.getSlots(); const stage = creator.createStage(); const method = creator.getStandardBracketFirstRoundOrdering(); diff --git a/app/features/tournament-bracket/core/engine/create/swiss.ts b/app/features/tournament-bracket/core/engine/create/swiss.ts index 5b4d1c72b..61c6b2c5f 100644 --- a/app/features/tournament-bracket/core/engine/create/swiss.ts +++ b/app/features/tournament-bracket/core/engine/create/swiss.ts @@ -1,14 +1,18 @@ import { TOURNAMENT } from "~/features/tournament/tournament-constants"; import { nullFilledArray } from "~/utils/arrays"; import invariant from "~/utils/invariant"; -import type { CreateBracketInput, CreatedBracket, MatchData } from "../types"; +import type { + CreatedBracket, + MatchData, + ResolvedCreateBracketInput, +} from "../types"; import { MatchStatus } from "../types"; /** * Creates a Swiss bracket data set: all rounds up front, matches for round 1 only. * Ported from the old core/Swiss.ts create()/firstRoundMatches(). */ -export function createSwiss(input: CreateBracketInput): CreatedBracket { +export function createSwiss(input: ResolvedCreateBracketInput): CreatedBracket { const swissSettings = input.settings?.swiss; const groupCount = @@ -56,7 +60,7 @@ function firstRoundMatches({ groupCount, roundCount, }: { - seeding: CreateBracketInput["seeding"]; + seeding: ResolvedCreateBracketInput["seeding"]; groupCount: number; roundCount: number; }): MatchData[] { diff --git a/app/features/tournament-bracket/core/engine/general.test.ts b/app/features/tournament-bracket/core/engine/general.test.ts index 1a40d9908..0dc24a812 100644 --- a/app/features/tournament-bracket/core/engine/general.test.ts +++ b/app/features/tournament-bracket/core/engine/general.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test } from "vitest"; +import { createResolved } from "./create"; import * as Engine from "./index"; import { EngineBracket } from "./test-utils"; @@ -15,7 +16,7 @@ describe("BYE handling", () => { tournamentId: 0, type: "double_elimination", seeding: [1, null, null, null], - settings: { seedOrdering: ["natural"] }, + settings: {}, }); expect(bracket.match(2).opponent1?.id).toBe(1); @@ -38,28 +39,6 @@ describe("BYE handling", () => { type: "double_elimination", seeding: [1, 2], settings: { - seedOrdering: ["natural"], - balanceByes: false, // Default value. - size: 4, - }, - }); - - expect(bracket.match(0).opponent1?.id).toBe(1); - expect(bracket.match(0).opponent2?.id).toBe(2); - - expect(bracket.match(1).opponent1).toBe(null); - expect(bracket.match(1).opponent2).toBe(null); - }); - - test("should balance BYEs in the seeding", () => { - bracket.create({ - name: "Example with BYEs", - tournamentId: 0, - type: "double_elimination", - seeding: [1, 2], - settings: { - seedOrdering: ["natural"], - balanceByes: true, size: 4, }, }); @@ -82,7 +61,6 @@ describe("Position checks", () => { type: "double_elimination", settings: { size: 8, - seedOrdering: ["natural"], }, }); }); @@ -102,7 +80,7 @@ describe("Position checks", () => { test("should have a position where we need the origin of a participant", () => { const matchFromWbRound1 = bracket.match(0); expect(matchFromWbRound1.opponent1?.position).toBe(1); - expect(matchFromWbRound1.opponent2?.position).toBe(2); + expect(matchFromWbRound1.opponent2?.position).toBe(8); const matchFromLbRound1 = bracket.match(7); expect(matchFromLbRound1.opponent1?.position).toBe(1); @@ -123,21 +101,21 @@ describe("Special cases", () => { test("should throw if the name of the stage is not provided", () => { expect(() => - Engine.create({ + createResolved({ tournamentId: 0, type: "single_elimination", settings: {}, - } as Engine.CreateBracketInput), + } as Engine.ResolvedCreateBracketInput), ).toThrow("You must provide a name for the stage."); }); test("should throw if the tournament id of the stage is not provided", () => { expect(() => - Engine.create({ + createResolved({ name: "Example", type: "single_elimination", settings: {}, - } as Engine.CreateBracketInput), + } as Engine.ResolvedCreateBracketInput), ).toThrow("You must provide a tournament id for the stage."); }); @@ -147,11 +125,11 @@ describe("Special cases", () => { tournamentId: 0, type: "single_elimination", seeding: [1, 2, 3, 4, 5, 6, 7], - settings: { seedOrdering: ["natural"] }, + settings: {}, }); - expect(bracket.match(3).opponent1?.id).toBe(7); - expect(bracket.match(3).opponent2).toBe(null); + expect(bracket.match(0).opponent1?.id).toBe(1); + expect(bracket.match(0).opponent2).toBe(null); }); test("should throw if the size of a stage is not a power of two", () => { @@ -199,15 +177,7 @@ describe("Seeding and ordering in elimination", () => { tournamentId: 0, type: "double_elimination", seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], - settings: { - seedOrdering: [ - "inner_outer", - "reverse", - "pair_flip", - "half_shift", - "reverse", - ], - }, + settings: {}, }); }); @@ -217,8 +187,8 @@ describe("Seeding and ordering in elimination", () => { expect(firstRoundMatchWB.opponent2?.position).toBe(16); const firstRoundMatchLB = bracket.match(15); - expect(firstRoundMatchLB.opponent1?.position).toBe(8); - expect(firstRoundMatchLB.opponent2?.position).toBe(7); + expect(firstRoundMatchLB.opponent1?.position).toBe(1); + expect(firstRoundMatchLB.opponent2?.position).toBe(2); const secondRoundMatchLB = bracket.match(19); expect(secondRoundMatchLB.opponent1?.position).toBe(2); @@ -240,13 +210,15 @@ describe("Reset match", () => { }); test("should reset results of a match", () => { + // Seeds 1 and 2 are placed into the same first-round match (positions 1 + // and 8) so that match 0 is a real two-team match under the default + // space_between ordering, while the rest of the bracket is BYEs. bracket.create({ name: "Example", tournamentId: 0, type: "single_elimination", - seeding: [1, 2], + seeding: [1, null, null, null, null, null, null, 2], settings: { - seedOrdering: ["natural"], size: 8, }, }); @@ -292,9 +264,7 @@ describe("Reset match", () => { tournamentId: 0, type: "single_elimination", seeding: [1, 2, 3, 4], - settings: { - seedOrdering: ["natural"], - }, + settings: {}, }); bracket.updateMatch({ @@ -321,12 +291,12 @@ describe("Reset match", () => { describe("Engine data immutability", () => { test("engine operations return new data and leave the input untouched", () => { - const initial = Engine.create({ + const initial = createResolved({ name: "Example", tournamentId: 0, type: "single_elimination", seeding: [1, 2, 3, 4], - settings: { seedOrdering: ["natural"] }, + settings: {}, }); const snapshot = structuredClone(initial); @@ -347,12 +317,12 @@ describe("Engine data immutability", () => { }); test("changedMatches contains only genuinely changed rows", () => { - const initial = Engine.create({ + const initial = createResolved({ name: "Example", tournamentId: 0, type: "single_elimination", seeding: [1, 2, 3, 4], - settings: { seedOrdering: ["natural"] }, + settings: {}, }); const afterReport = Engine.reportResult(initial, { diff --git a/app/features/tournament-bracket/core/engine/helpers.test.ts b/app/features/tournament-bracket/core/engine/helpers.test.ts index 25d49f3f0..c141584cb 100644 --- a/app/features/tournament-bracket/core/engine/helpers.test.ts +++ b/app/features/tournament-bracket/core/engine/helpers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { balanceByes, ordering } from "./create/seeding"; +import { ordering } from "./create/seeding"; import { assertAbDivisionRoundRobin, assertRoundRobin, @@ -288,32 +288,6 @@ describe("A/B division group distribution", () => { }); describe("Seed ordering methods", () => { - test("should place 2 participants with inner-outer method", () => { - const teams = [1, 2]; - const placement = ordering.inner_outer(teams); - expect(placement).toEqual([1, 2]); - }); - - test("should place 4 participants with inner-outer method", () => { - const teams = [1, 2, 3, 4]; - const placement = ordering.inner_outer(teams); - expect(placement).toEqual([1, 4, 2, 3]); - }); - - test("should place 8 participants with inner-outer method", () => { - const teams = [1, 2, 3, 4, 5, 6, 7, 8]; - const placement = ordering.inner_outer(teams); - expect(placement).toEqual([1, 8, 4, 5, 2, 7, 3, 6]); - }); - - test("should place 16 participants with inner-outer method", () => { - const teams = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]; - const placement = ordering.inner_outer(teams); - expect(placement).toEqual([ - 1, 16, 8, 9, 4, 13, 5, 12, 2, 15, 7, 10, 3, 14, 6, 11, - ]); - }); - test("should make a natural ordering", () => { expect(ordering.natural([1, 2, 3, 4, 5, 6, 7, 8])).toEqual([ 1, 2, 3, 4, 5, 6, 7, 8, @@ -344,23 +318,6 @@ describe("Seed ordering methods", () => { ]); }); - test("should make an effort balanced ordering for groups", () => { - expect( - ordering["groups.effort_balanced"]([1, 2, 3, 4, 5, 6, 7, 8], 4), - ).toEqual([1, 5, 2, 6, 3, 7, 4, 8]); - - expect( - ordering["groups.effort_balanced"]( - [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], - 4, - ), - ).toEqual([1, 5, 9, 13, 2, 6, 10, 14, 3, 7, 11, 15, 4, 8, 12, 16]); - - expect( - ordering["groups.effort_balanced"]([1, 2, 3, 4, 5, 6, 7, 8], 2), - ).toEqual([1, 3, 5, 7, 2, 4, 6, 8]); - }); - test("should make a snake ordering for groups", () => { expect( ordering["groups.seed_optimized"]([1, 2, 3, 4, 5, 6, 7, 8], 4), @@ -378,132 +335,3 @@ describe("Seed ordering methods", () => { ).toEqual([1, 4, 5, 8, 2, 3, 6, 7]); }); }); - -describe("Balance BYEs", () => { - test("should ignore input BYEs in the seeding", () => { - expect( - balanceByes( - [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, null, null, null, null], - 16, - ), - ).toEqual(balanceByes([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], 16)); - - expect( - balanceByes( - [1, 2, 3, null, 4, 5, 6, 7, 8, null, 9, 10, null, 11, null, 12, null], - 16, - ), - ).toEqual(balanceByes([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], 16)); - }); - - test("should take the target size as an argument or calculate it", () => { - expect(balanceByes([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], 16)).toEqual( - balanceByes([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), - ); - }); - - test("should prefer matches with only one BYE", () => { - expect( - balanceByes([ - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - null, - null, - null, - null, - ]), - ).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, null, 10, null, 11, null, 12, null]); - - expect( - balanceByes( - [ - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - null, - null, - null, - null, - null, - null, - null, - null, - ], - 16, - ), - ).toEqual([ - 1, - null, - 2, - null, - 3, - null, - 4, - null, - 5, - null, - 6, - null, - 7, - null, - 8, - null, - ]); - - expect( - balanceByes( - [ - 1, - 2, - 3, - 4, - 5, - 6, - 7, - null, - null, - null, - null, - null, - null, - null, - null, - null, - ], - 16, - ), - ).toEqual([ - 1, - null, - 2, - null, - 3, - null, - 4, - null, - 5, - null, - 6, - null, - 7, - null, - null, - null, - ]); - }); -}); diff --git a/app/features/tournament-bracket/core/engine/helpers.ts b/app/features/tournament-bracket/core/engine/helpers.ts index 370aee2fd..985f736f6 100644 --- a/app/features/tournament-bracket/core/engine/helpers.ts +++ b/app/features/tournament-bracket/core/engine/helpers.ts @@ -1,4 +1,4 @@ -import { ordering } from "./create/seeding"; +import { defaultMinorOrdering, ordering } from "./create/seeding"; import type { DeepPartial, Duel, @@ -1067,15 +1067,14 @@ function getLoserRoundLoserCount( /** * Returns the ordering method of a round of a loser bracket. * - * @param seedOrdering The list of seed orderings. + * @param participantCount The number of participants in the stage. * @param roundNumber Number of the round. */ export function getLoserOrdering( - seedOrdering: SeedOrdering[], + participantCount: number, roundNumber: number, ): SeedOrdering | undefined { - const orderingIndex = 1 + Math.floor(roundNumber / 2); - return seedOrdering[orderingIndex]; + return defaultMinorOrdering[participantCount]?.[Math.floor(roundNumber / 2)]; } /** diff --git a/app/features/tournament-bracket/core/engine/index.ts b/app/features/tournament-bracket/core/engine/index.ts index a248567b2..34e1308a3 100644 --- a/app/features/tournament-bracket/core/engine/index.ts +++ b/app/features/tournament-bracket/core/engine/index.ts @@ -5,6 +5,7 @@ */ export { create } from "./create"; +export { hasThirdPlaceMatch, roundRobinGroupCount } from "./create/settings"; export { endDroppedTeamMatches } from "./propagation/dropped-teams"; export { reportResult } from "./propagation/report-result"; export { resetMatchResults } from "./propagation/reset-result"; diff --git a/app/features/tournament-bracket/core/engine/propagation/double-elimination.test.ts b/app/features/tournament-bracket/core/engine/propagation/double-elimination.test.ts index d09cab4a9..c08695cca 100644 --- a/app/features/tournament-bracket/core/engine/propagation/double-elimination.test.ts +++ b/app/features/tournament-bracket/core/engine/propagation/double-elimination.test.ts @@ -15,7 +15,7 @@ describe("Previous and next match update in double elimination stage", () => { tournamentId: 0, type: "double_elimination", seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], - settings: { seedOrdering: ["natural"] }, + settings: {}, }); const before = bracket.match(8); // First match of WB round 2 @@ -191,55 +191,51 @@ describe("Previous and next match update in double elimination stage", () => { tournamentId: 0, type: "double_elimination", seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], - settings: { - seedOrdering: ["natural", "reverse", "reverse"], - }, + settings: {}, }); bracket.updateMatch({ id: 0, opponent1: { result: "win" } }); // WB 1.1 expect( - bracket.match(18).opponent2?.id, // Determined opponent for last match of LB round 1 (reverse ordering for losers) + bracket.match(15).opponent1?.id, // Determined opponent for first match of LB round 1 (natural ordering for losers) ).toBe(bracket.match(0).opponent2?.id); // Loser of first match round 1 bracket.updateMatch({ id: 1, opponent1: { result: "win" } }); // WB 1.2 expect( - bracket.match(18).opponent1?.id, // Determined opponent for last match of LB round 1 (reverse ordering for losers) + bracket.match(15).opponent2?.id, // Determined opponent for first match of LB round 1 (natural ordering for losers) ).toBe(bracket.match(1).opponent2?.id); // Loser of second match round 1 bracket.updateMatch({ id: 8, opponent1: { result: "win" } }); // WB 2.1 expect( - bracket.match(22).opponent1?.id, // Determined opponent for last match of LB round 2 (reverse ordering for losers) + bracket.match(20).opponent1?.id, // Determined opponent for first match of LB round 2 ).toBe(bracket.match(8).opponent2?.id); // Loser of first match round 2 bracket.updateMatch({ id: 6, opponent1: { result: "win" } }); // WB 1.7 bracket.updateMatch({ id: 7, opponent1: { result: "win" } }); // WB 1.8 bracket.updateMatch({ id: 11, opponent1: { result: "win" } }); // WB 2.4 bracket.updateMatch({ id: 15, opponent1: { result: "win" } }); // LB 1.1 - bracket.updateMatch({ id: 19, opponent1: { result: "win" } }); // LB 2.1 + bracket.updateMatch({ id: 18, opponent1: { result: "win" } }); // LB 1.4 expect(bracket.match(8).status).toBe(TournamentMatchStatus.Completed); // WB 2.1 }); test("should send the losers to the right LB matches in round 1", () => { bracket.create({ - name: "Example with inner_outer loser ordering", + name: "Example with natural loser ordering", tournamentId: 0, type: "double_elimination", seeding: [1, 2, 3, 4, 5, 6, 7, 8], - settings: { - seedOrdering: ["inner_outer", "inner_outer"], - }, + settings: {}, }); expect(bracket.match(7).opponent1?.position).toBe(1); - expect(bracket.match(7).opponent2?.position).toBe(4); - expect(bracket.match(8).opponent1?.position).toBe(2); - expect(bracket.match(8).opponent2?.position).toBe(3); + expect(bracket.match(7).opponent2?.position).toBe(2); + expect(bracket.match(8).opponent1?.position).toBe(3); + expect(bracket.match(8).opponent2?.position).toBe(4); // Match of position 1. bracket.updateMatch({ id: 0, - opponent1: { result: "win" }, // Loser id: 7. + opponent1: { result: "win" }, // Loser id: 8. }); expect(bracket.match(7).opponent1?.id).toBe(8); @@ -247,25 +243,25 @@ describe("Previous and next match update in double elimination stage", () => { // Match of position 2. bracket.updateMatch({ id: 1, - opponent1: { result: "win" }, // Loser id: 4. + opponent1: { result: "win" }, // Loser id: 5. }); - expect(bracket.match(8).opponent1?.id).toBe(5); + expect(bracket.match(7).opponent2?.id).toBe(5); // Match of position 3. bracket.updateMatch({ id: 2, - opponent1: { result: "win" }, // Loser id: 6. + opponent1: { result: "win" }, // Loser id: 7. }); - expect(bracket.match(8).opponent2?.id).toBe(7); + expect(bracket.match(8).opponent1?.id).toBe(7); // Match of position 4. bracket.updateMatch({ id: 3, - opponent1: { result: "win" }, // Loser id: 5. + opponent1: { result: "win" }, // Loser id: 6. }); - expect(bracket.match(7).opponent2?.id).toBe(6); + expect(bracket.match(8).opponent2?.id).toBe(6); }); }); diff --git a/app/features/tournament-bracket/core/engine/propagation/dropped-teams.ts b/app/features/tournament-bracket/core/engine/propagation/dropped-teams.ts index c405c74d5..14ea550c5 100644 --- a/app/features/tournament-bracket/core/engine/propagation/dropped-teams.ts +++ b/app/features/tournament-bracket/core/engine/propagation/dropped-teams.ts @@ -4,9 +4,8 @@ import { Propagator } from "./traversal"; /** * Ends all unfinished matches involving dropped teams by awarding wins to - * their opponents. Ported from tournament-utils.server.ts. `randomPick` is - * injected so the engine stays deterministic/testable (caller passes a - * Math.random-backed impl in production). + * their opponents. Ported from tournament-utils.server.ts. When both teams in + * a match have dropped, a random winner is picked. * * Matches that only gain a dropped opponent through propagation caused by this * call are not ended (same as the old implementation, which iterated a @@ -14,14 +13,11 @@ import { Propagator } from "./traversal"; */ export function endDroppedTeamMatches( data: BracketData, - args: { - droppedTeamIds: number[]; - randomPick: (a: number, b: number) => number; - }, + droppedTeamIds: number[], ): DroppedTeamsResult { const store = new Store(data); const propagator = new Propagator(store); - const droppedTeamIds = new Set(args.droppedTeamIds); + const droppedTeamIdsSet = new Set(droppedTeamIds); const endedMatchIds: number[] = []; @@ -30,15 +26,15 @@ export function endDroppedTeamMatches( if (match.opponent1.result === "win" || match.opponent2.result === "win") continue; - const team1Dropped = droppedTeamIds.has(match.opponent1.id); - const team2Dropped = droppedTeamIds.has(match.opponent2.id); + const team1Dropped = droppedTeamIdsSet.has(match.opponent1.id); + const team2Dropped = droppedTeamIdsSet.has(match.opponent2.id); if (!team1Dropped && !team2Dropped) continue; const winnerTeamId = (() => { if (team1Dropped && !team2Dropped) return match.opponent2.id; if (!team1Dropped && team2Dropped) return match.opponent1.id; - return args.randomPick(match.opponent1.id, match.opponent2.id); + return Math.random() < 0.5 ? match.opponent1.id : match.opponent2.id; })(); const stored = store.select("match", match.id); diff --git a/app/features/tournament-bracket/core/engine/propagation/traversal.ts b/app/features/tournament-bracket/core/engine/propagation/traversal.ts index 212f905c6..50fbc0127 100644 --- a/app/features/tournament-bracket/core/engine/propagation/traversal.ts +++ b/app/features/tournament-bracket/core/engine/propagation/traversal.ts @@ -649,10 +649,7 @@ export class Propagator { const roundNumberLB = roundNumber > 1 ? (roundNumber - 1) * 2 : 1; const participantCount = stage.settings.size!; - const method = helpers.getLoserOrdering( - stage.settings.seedOrdering!, - roundNumberLB, - ); + const method = helpers.getLoserOrdering(participantCount, roundNumberLB); const actualMatchNumberLB = helpers.findLoserMatchNumber( participantCount, roundNumberLB, diff --git a/app/features/tournament-bracket/core/engine/propagation/update.test.ts b/app/features/tournament-bracket/core/engine/propagation/update.test.ts index 3692fdbe3..dd7bf3f1e 100644 --- a/app/features/tournament-bracket/core/engine/propagation/update.test.ts +++ b/app/features/tournament-bracket/core/engine/propagation/update.test.ts @@ -9,7 +9,7 @@ const example = { tournamentId: 0, type: "double_elimination" as const, seeding: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16], - settings: { seedOrdering: ["natural" as const] }, + settings: {}, }; describe("Update matches", () => { @@ -82,7 +82,7 @@ describe("Update matches", () => { const nextMatch = bracket.match(8); expect(nextMatch.status).toBe(TournamentMatchStatus.Waiting); - expect(nextMatch.opponent1?.id).toBe(2); + expect(nextMatch.opponent1?.id).toBe(16); }); test("should update the status of the next match", () => { @@ -208,7 +208,7 @@ describe("Give opponent IDs when updating", () => { tournamentId: 0, type: "double_elimination", seeding: [1, 2, 3, 4], - settings: { seedOrdering: ["natural"] }, + settings: {}, }); }); @@ -216,7 +216,7 @@ describe("Give opponent IDs when updating", () => { bracket.updateMatch({ id: 0, opponent1: { - id: 2, + id: 4, score: 10, }, opponent2: { @@ -235,7 +235,7 @@ describe("Give opponent IDs when updating", () => { bracket.updateMatch({ id: 0, opponent1: { - id: 2, + id: 4, score: 10, }, }); diff --git a/app/features/tournament-bracket/core/engine/test-utils.ts b/app/features/tournament-bracket/core/engine/test-utils.ts index ea31756bd..c058a1ccc 100644 --- a/app/features/tournament-bracket/core/engine/test-utils.ts +++ b/app/features/tournament-bracket/core/engine/test-utils.ts @@ -1,13 +1,14 @@ // Test-only harness giving the pure engine a stateful surface similar to the // old BracketsManager, so the vendored test suite could be ported 1:1. +import { createResolved } from "./create"; import * as Engine from "./index"; import type { BracketData, - CreateBracketInput, GroupData, MatchData, ParticipantResult, + ResolvedCreateBracketInput, RoundData, StageData, } from "./types"; @@ -23,10 +24,13 @@ export class EngineBracket { data: BracketData | undefined; create( - input: Omit & - Partial>, + input: Omit & + Partial>, ): void { - const created = Engine.create({ ...input, settings: input.settings ?? {} }); + const created = createResolved({ + ...input, + settings: input.settings ?? {}, + }); if (!this.data) { this.data = created; diff --git a/app/features/tournament-bracket/core/engine/types.ts b/app/features/tournament-bracket/core/engine/types.ts index 4e267e5bc..4162c17d1 100644 --- a/app/features/tournament-bracket/core/engine/types.ts +++ b/app/features/tournament-bracket/core/engine/types.ts @@ -1,4 +1,8 @@ -import type { Tables, TournamentRoundMaps } from "~/db/tables"; +import type { + Tables, + TournamentRoundMaps, + TournamentStageSettings, +} from "~/db/tables"; /** * Match/set outcome for one side. Upstream brackets-model also had "draw" — @@ -21,18 +25,14 @@ export type GroupType = | "loser_bracket" | "final_group"; -// xxx: what can we delete here? export type SeedOrdering = | "natural" | "reverse" | "half_shift" | "reverse_half_shift" | "pair_flip" - | "inner_outer" | "space_between" - | "groups.effort_balanced" - | "groups.seed_optimized" - | "groups.bracket_optimized"; + | "groups.seed_optimized"; /** The seeding for a stage. Each element is a participant id or a BYE: `null`. */ export type Seeding = (number | null)[]; @@ -62,23 +62,6 @@ export interface StageSettings { /** The number of participants. */ size?: number; - /** - * A list of ordering methods to apply to the seeding. - * - * - For a round-robin stage: 1 item required (**with** `"groups."` prefix). - * - For a simple elimination stage, 1 item required (**without** `"groups."` prefix). - * - For a double elimination stage, 1 item required, 3+ items supported (**without** `"groups."` prefix). - * - Item 1 (required) - Used to distribute in WB round 1. - * - Item 2 - Used to distribute WB losers in LB round 1. - * - Items 3+ - Used to distribute WB losers in LB minor rounds (1 per round). - */ - // xxx: make implementation detail? - seedOrdering?: SeedOrdering[]; - - /** Whether to balance BYEs in the seeding of an elimination stage. */ - // xxx: make implementation detail? - balanceByes?: boolean; - /** Number of groups in a round-robin stage. */ groupCount?: number; @@ -233,15 +216,32 @@ export interface CreateBracketInput { tournamentId: number; name: string; type: StageType; - /** Team ids in seed order; `null` = BYE. When omitted, `settings.size` must be given (TBD slots). */ - seeding?: Seeding; - settings: StageSettings; + /** Team ids in seed order; `null` = BYE. */ + seeding: Seeding; + /** User-selected settings; the engine derives its internal stage settings (defaults, group counts, seed ordering) from these. */ + settings: TournamentStageSettings | null; + /** (Round robin only) Whether matches are playable independently of rounds (league divisions). */ + independentRounds?: boolean; /** Parallel to seeding; required when settings.hasAbDivisions. 0 = A, 1 = B. */ abDivisions?: (0 | 1)[]; /** Stage number within the tournament. Defaults to 1 (local data; the repository assigns the real number on insert). */ number?: number; } +/** + * Engine-internal variant of {@link CreateBracketInput}: settings are the + * already-resolved internal {@link StageSettings} and seeding may be omitted + * in favor of `settings.size` (TBD slots). + */ +export interface ResolvedCreateBracketInput + extends Omit< + CreateBracketInput, + "seeding" | "settings" | "independentRounds" + > { + seeding?: Seeding; + settings: StageSettings; +} + /** Mirrors the old manager.update.match() partial-update input. */ export interface ReportResultInput { matchId: number; @@ -312,6 +312,5 @@ export interface DroppedTeamsResult extends EngineResult { // xxx: just rename export type TournamentManagerDataSet = BracketData; -export type Stage = StageData; export type Round = RoundData; export type Match = MatchData; diff --git a/app/features/tournament-bracket/core/tests/mocks-li.ts b/app/features/tournament-bracket/core/tests/mocks-li.ts index 6d8c258d8..329606fd4 100644 --- a/app/features/tournament-bracket/core/tests/mocks-li.ts +++ b/app/features/tournament-bracket/core/tests/mocks-li.ts @@ -24,12 +24,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ number: 2, settings: { size: 16, - seedOrdering: [ - "space_between", - "natural", - "reverse_half_shift", - "reverse", - ], }, tournament_id: 815, type: "double_elimination", @@ -41,12 +35,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ number: 3, settings: { size: 16, - seedOrdering: [ - "space_between", - "natural", - "reverse_half_shift", - "reverse", - ], }, tournament_id: 815, type: "double_elimination", @@ -58,12 +46,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({ number: 4, settings: { size: 16, - seedOrdering: [ - "space_between", - "natural", - "reverse_half_shift", - "reverse", - ], }, tournament_id: 815, type: "double_elimination", diff --git a/app/features/tournament-bracket/core/tests/mocks-sos.ts b/app/features/tournament-bracket/core/tests/mocks-sos.ts index 28101f609..b96028151 100644 --- a/app/features/tournament-bracket/core/tests/mocks-sos.ts +++ b/app/features/tournament-bracket/core/tests/mocks-sos.ts @@ -12,7 +12,6 @@ export const SWIM_OR_SINK_167 = ( number: 1, settings: { groupCount: 11, - seedOrdering: ["groups.seed_optimized"], size: 44, }, tournament_id: 672, diff --git a/app/features/tournament-bracket/core/tests/mocks.ts b/app/features/tournament-bracket/core/tests/mocks.ts index 6c210b0ad..25d57b837 100644 --- a/app/features/tournament-bracket/core/tests/mocks.ts +++ b/app/features/tournament-bracket/core/tests/mocks.ts @@ -10,7 +10,6 @@ export const PADDLING_POOL_257 = () => number: 1, settings: { groupCount: 9, - seedOrdering: ["groups.seed_optimized"], size: 35, }, tournament_id: 27, @@ -6823,7 +6822,6 @@ export const PADDLING_POOL_255 = () => number: 1, settings: { groupCount: 9, - seedOrdering: ["groups.seed_optimized"], size: 35, }, tournament_id: 18, @@ -14214,13 +14212,6 @@ export const IN_THE_ZONE_32 = ({ number: 1, settings: { size: 32, - seedOrdering: [ - "space_between", - "natural", - "reverse", - "half_shift", - "natural", - ], }, tournament_id: 11, type: "double_elimination", diff --git a/app/features/tournament/core/Standings.test.ts b/app/features/tournament/core/Standings.test.ts index 53b67f5c5..dc8684a40 100644 --- a/app/features/tournament/core/Standings.test.ts +++ b/app/features/tournament/core/Standings.test.ts @@ -164,14 +164,14 @@ function roundRobinToSingleEliminationTournament() { tournamentId: 1, type: "round_robin", seeding: [1, 2, 3, 4], - settings: { groupCount: 1, seedOrdering: ["groups.seed_optimized"] }, + settings: { groupCount: 1 }, }); bracket.create({ name: "B1", tournamentId: 1, type: "single_elimination", seeding: [1, 2], - settings: { seedOrdering: ["natural"] }, + settings: {}, }); // play every match across both brackets, lower id always wins @@ -219,7 +219,7 @@ function singleEliminationTournament() { tournamentId: 1, type: "single_elimination", seeding: [1, 2, 3, 4], - settings: { seedOrdering: ["natural"] }, + settings: {}, }); while (true) { @@ -270,7 +270,6 @@ function abDivisionsTournament() { settings: { groupCount: 1, hasAbDivisions: true, - seedOrdering: ["groups.seed_optimized"], }, }); diff --git a/app/features/tournament/tournament-test-utils.ts b/app/features/tournament/tournament-test-utils.ts index a78be91ad..2ddd96f7a 100644 --- a/app/features/tournament/tournament-test-utils.ts +++ b/app/features/tournament/tournament-test-utils.ts @@ -111,12 +111,6 @@ export async function dbStartTournament(seeding: number[], tournamentId = 1) { const bracket = tournament.bracketByIdx(0)!; - const settings = tournament.bracketManagerSettings( - bracket.settings, - bracket.type, - seeding.length, - ); - await BracketRepository.insertBracket({ tournamentId: tournament.ctx.id, bracket: Engine.create({ @@ -124,7 +118,7 @@ export async function dbStartTournament(seeding: number[], tournamentId = 1) { name: bracket.name, type: bracket.type, seeding, - settings, + settings: bracket.settings, }), }); diff --git a/app/features/tournament/tournament-utils.server.ts b/app/features/tournament/tournament-utils.server.ts index 57910de26..7fa5ae8b9 100644 --- a/app/features/tournament/tournament-utils.server.ts +++ b/app/features/tournament/tournament-utils.server.ts @@ -91,10 +91,7 @@ export function endDroppedTeamMatches({ .map((team) => team.id); if (typeof droppedTeamId === "number") droppedTeamIds.push(droppedTeamId); - const result = Engine.endDroppedTeamMatches(data, { - droppedTeamIds, - randomPick: (a, b) => (Math.random() < 0.5 ? a : b), // xxx: why randomPick a param like this? - }); + const result = Engine.endDroppedTeamMatches(data, droppedTeamIds); // xxx: maybe move to caller? or repository function for (const matchId of result.endedMatchIds) {