Remove stuff

This commit is contained in:
Kalle
2026-07-23 18:36:46 +03:00
parent c37b077627
commit 6d96069d56
34 changed files with 283 additions and 683 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: <T>(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": <T>(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": <T>(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.
*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<CreateBracketInput, "settings"> &
Partial<Pick<CreateBracketInput, "settings">>,
input: Omit<ResolvedCreateBracketInput, "settings"> &
Partial<Pick<ResolvedCreateBracketInput, "settings">>,
): void {
const created = Engine.create({ ...input, settings: input.settings ?? {} });
const created = createResolved({
...input,
settings: input.settings ?? {},
});
if (!this.data) {
this.data = created;

View File

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

View File

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

View File

@@ -12,7 +12,6 @@ export const SWIM_OR_SINK_167 = (
number: 1,
settings: {
groupCount: 11,
seedOrdering: ["groups.seed_optimized"],
size: 44,
},
tournament_id: 672,

View File

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

View File

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

View File

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

View File

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