Fix sibling bracket detection to use depth instead of source indices (#2582)

- Fixes issue where sibling brackets at the same progression depth weren't matched when they had different source indices
- Adds `bracketDepth()` function to calculate bracket depth in progression tree
- Updates sibling bracket detection to compare depth instead of source bracket indices
- Adds test coverage for multiple parallel starting groups scenario
This commit is contained in:
Kalle
2025-10-16 18:51:38 +03:00
committed by GitHub
parent c71208c068
commit 905670a56e
4 changed files with 142 additions and 9 deletions

View File

@@ -133,6 +133,66 @@ describe("PreparedMaps - resolvePreparedForTheBracket", () => {
expect(prepared).toBeNull();
});
test("multiple starting brackets (RR) feeding into SE brackets at same depth should share maps", () => {
const tournamentWithTwoStartingBrackets = testTournament({
ctx: {
settings: {
bracketProgression: [
{
type: "round_robin",
name: "Group A",
requiresCheckIn: false,
settings: {},
},
{
type: "round_robin",
name: "Group B",
requiresCheckIn: false,
settings: {},
},
{
type: "single_elimination",
name: "SE from Group A",
requiresCheckIn: false,
settings: {},
sources: [
{
bracketIdx: 0,
placements: [1, 2],
},
],
},
{
type: "single_elimination",
name: "SE from Group B",
requiresCheckIn: false,
settings: {},
sources: [
{
bracketIdx: 1,
placements: [1, 2],
},
],
},
],
},
},
});
const prepared = PreparedMaps.resolvePreparedForTheBracket({
tournament: tournamentWithTwoStartingBrackets,
bracketIdx: 3,
preparedByBracket: [
null,
null,
{ authorId: 1, createdAt: 1, maps: [] },
null,
],
});
expect(prepared).not.toBeNull();
});
});
describe("PreparedMaps - eliminationTeamCountOptions", () => {

View File

@@ -3,9 +3,10 @@ import type { PreparedMaps } from "~/db/tables";
import { nullFilledArray } from "~/utils/arrays";
import invariant from "~/utils/invariant";
import type { Bracket } from "./Bracket";
import * as Progression from "./Progression";
import type { Tournament } from "./Tournament";
/** Returns the prepared maps for one exact bracket index OR maps of a "sibling bracket" i.e. bracket that has the same sources */
/** Returns the prepared maps for one exact bracket index OR maps of a "sibling bracket" i.e. bracket that has the same depth in progression */
export function resolvePreparedForTheBracket({
preparedByBracket,
bracketIdx,
@@ -23,19 +24,20 @@ export function resolvePreparedForTheBracket({
}
const bracketPreparingFor = tournament.bracketByIdx(bracketIdx)!;
const bracketProgression = tournament.ctx.settings.bracketProgression;
const targetDepth = Progression.bracketDepth(bracketIdx, bracketProgression);
// lets look for an "equivalent" prepared bracket to use
// e.g. SoS RR -> 4x SE style the SE brackets can share maps
for (const [
anotherBracketIdx,
bracket,
] of tournament.ctx.settings.bracketProgression.entries()) {
for (const [anotherBracketIdx, bracket] of bracketProgression.entries()) {
const depth = Progression.bracketDepth(
anotherBracketIdx,
bracketProgression,
);
if (
bracket.type === bracketPreparingFor.type &&
R.isDeepEqual(
bracket.sources?.map((s) => s.bracketIdx),
bracketPreparingFor.sources?.map((s) => s.bracketIdx),
) &&
depth === targetDepth &&
R.isDeepEqual(bracket.settings, bracketPreparingFor.settings)
) {
const bracketMaps = preparedByBracket?.[anotherBracketIdx];

View File

@@ -814,3 +814,53 @@ describe("validatedBracketsToInputFormat", () => {
]);
});
});
describe("bracketDepth", () => {
it("returns 0 for starting bracket with no sources", () => {
expect(Progression.bracketDepth(0, progressions.singleElimination)).toBe(0);
});
it("returns 0 for starting bracket and 1 for bracket sourced from it", () => {
expect(
Progression.bracketDepth(0, progressions.roundRobinToSingleElimination),
).toBe(0);
expect(
Progression.bracketDepth(1, progressions.roundRobinToSingleElimination),
).toBe(1);
});
it("handles complex progression with multiple depth levels", () => {
expect(Progression.bracketDepth(0, progressions.lowInk)).toBe(0);
expect(Progression.bracketDepth(1, progressions.lowInk)).toBe(1);
expect(Progression.bracketDepth(2, progressions.lowInk)).toBe(1);
expect(Progression.bracketDepth(3, progressions.lowInk)).toBe(2);
});
it("handles multiple starting brackets", () => {
expect(Progression.bracketDepth(0, progressions.manyStartBrackets)).toBe(0);
expect(Progression.bracketDepth(1, progressions.manyStartBrackets)).toBe(0);
expect(Progression.bracketDepth(2, progressions.manyStartBrackets)).toBe(1);
expect(Progression.bracketDepth(3, progressions.manyStartBrackets)).toBe(1);
});
it("handles underground brackets", () => {
expect(
Progression.bracketDepth(
0,
progressions.doubleEliminationWithUnderground,
),
).toBe(0);
expect(
Progression.bracketDepth(
1,
progressions.doubleEliminationWithUnderground,
),
).toBe(1);
});
it("throws if given idx is out of bounds", () => {
expect(() =>
Progression.bracketDepth(1, progressions.singleElimination),
).toThrow();
});
});

View File

@@ -598,6 +598,27 @@ export function isUnderground(idx: number, brackets: ParsedBracket[]) {
return !resolveMainBracketProgression(brackets).includes(idx);
}
/**
* Returns the depth of a bracket in the tournament progression.
* Depth is the distance from a starting bracket (bracket with no sources).
* Starting brackets have depth 0, brackets sourced from them have depth 1, etc.
*/
export function bracketDepth(idx: number, brackets: ParsedBracket[]): number {
invariant(idx < brackets.length, "Bracket index out of bounds");
const bracket = brackets[idx];
if (!bracket.sources || bracket.sources.length === 0) {
return 0;
}
const sourceDepths = bracket.sources.map((source) =>
bracketDepth(source.bracketIdx, brackets),
);
return Math.max(...sourceDepths) + 1;
}
function resolveMainBracketProgression(brackets: ParsedBracket[]) {
if (brackets.length === 1) return [0];