Skip to content

Commit 0e63c97

Browse files
authored
Fix round-robin pool ordering and balance uneven groups (#249)
1 parent 49c37ea commit 0e63c97

5 files changed

Lines changed: 133 additions & 20 deletions

File tree

src/base/stage/creator.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -663,8 +663,14 @@ export class StageCreator {
663663

664664
const positions = this.stage.settings?.manualOrdering.flat();
665665
const slots = await this.getSlots(positions);
666+
let cursor = 0;
666667

667-
return helpers.makeGroups(slots, this.stage.settings.groupCount);
668+
return this.stage.settings.manualOrdering.map(group => {
669+
const slotsGroup = slots.slice(cursor, cursor + group.length);
670+
cursor += group.length;
671+
672+
return slotsGroup;
673+
});
668674
}
669675

670676
if (Array.isArray(this.stage.settings.seedOrdering) && this.stage.settings.seedOrdering.length !== 1)

src/helpers.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,10 @@ export function makeRoundRobinMatches<T>(participants: T[], mode: RoundRobinMode
8383
if (mode === 'simple')
8484
return distribution;
8585

86-
// Reverse rounds and their content.
87-
const symmetry = distribution.map(round => [...round].reverse()).reverse();
86+
// Mirror the same rounds with inverted home/away sides.
87+
const symmetry = distribution.map(round =>
88+
round.map(([opponent1, opponent2]) => [opponent2, opponent1] as [T, T]),
89+
);
8890

8991
return [...distribution, ...symmetry];
9092
}
@@ -173,14 +175,16 @@ export function assertRoundRobin(input: number[], output: [number, number][][]):
173175
* @param groupCount The group count.
174176
*/
175177
export function makeGroups<T>(elements: T[], groupCount: number): T[][] {
176-
const groupSize = Math.ceil(elements.length / groupCount);
177178
const result: T[][] = [];
179+
const minGroupSize = Math.floor(elements.length / groupCount);
180+
const extraGroups = elements.length % groupCount;
181+
let cursor = 0;
178182

179-
for (let i = 0; i < elements.length; i++) {
180-
if (i % groupSize === 0)
181-
result.push([]);
183+
for (let i = 0; i < groupCount; i++) {
184+
const groupSize = minGroupSize + (i < extraGroups ? 1 : 0);
185+
result.push(elements.slice(cursor, cursor + groupSize));
182186

183-
result[result.length - 1].push(elements[i]);
187+
cursor += groupSize;
184188
}
185189

186190
return result;

src/ordering.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,22 @@ export const ordering: OrderingMap = {
4848
},
4949
'groups.seed_optimized': <T>(array: T[], groupCount: number) => {
5050
const groups = Array.from({ length: groupCount }, (_): T[] => []);
51-
52-
for (let run = 0; run < array.length / groupCount; run++) {
53-
if (run % 2 === 0) {
54-
for (let group = 0; group < groupCount; group++)
55-
groups[group].push(array[run * groupCount + group]);
56-
57-
} else {
58-
for (let group = 0; group < groupCount; group++)
59-
groups[groupCount - group - 1].push(array[run * groupCount + group]);
60-
51+
const minGroupSize = Math.floor(array.length / groupCount);
52+
const extraGroups = array.length % groupCount;
53+
const groupSizes = groups.map((_, group) => minGroupSize + (group < extraGroups ? 1 : 0));
54+
let index = 0;
55+
56+
for (let run = 0; index < array.length; run++) {
57+
const groupOrder = run % 2 === 0
58+
? groups.map((_, group) => group)
59+
: groups.map((_, group) => groupCount - group - 1);
60+
61+
for (const group of groupOrder) {
62+
if (index >= array.length) break;
63+
if (groups[group].length >= groupSizes[group]) continue;
64+
65+
groups[group].push(array[index]);
66+
index++;
6167
}
6268
}
6369

@@ -120,15 +126,17 @@ export const ordering: OrderingMap = {
120126
for (let i = 0; i < pairCount; i++) {
121127
const base = baseGroupForPair(i);
122128
const aIndex = positions[2 * i] - 1; // convert to 0-based
123-
groups[base].push(array[aIndex]);
129+
if (aIndex < array.length)
130+
groups[base].push(array[aIndex]);
124131
}
125132

126133
// Then, distribute the second element of each pair to the opposite
127134
// group to avoid having bracket-opponents in the same group.
128135
for (let i = 0; i < pairCount; i++) {
129136
const base = baseGroupForPair(i);
130137
const bIndex = positions[2 * i + 1] - 1; // convert to 0-based
131-
groups[(base + halfGroupCount) % groupCount].push(array[bIndex]);
138+
if (bIndex < array.length)
139+
groups[(base + halfGroupCount) % groupCount].push(array[bIndex]);
132140
}
133141

134142
// Sort each group by original seed order so the strongest seed of the

test/helpers.spec.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ describe('Helpers', () => {
1010
assert.deepStrictEqual(makeGroups([1, 2, 3, 4, 5], 2), [[1, 2, 3], [4, 5]]);
1111
assert.deepStrictEqual(makeGroups([1, 2, 3, 4, 5, 6, 7, 8], 2), [[1, 2, 3, 4], [5, 6, 7, 8]]);
1212
assert.deepStrictEqual(makeGroups([1, 2, 3, 4, 5, 6, 7, 8], 3), [[1, 2, 3], [4, 5, 6], [7, 8]]);
13+
assert.deepStrictEqual(makeGroups([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], 4), [
14+
[1, 2, 3, 4],
15+
[5, 6, 7, 8],
16+
[9, 10, 11],
17+
[12, 13, 14],
18+
]);
1319
});
1420

1521
it('should make the rounds for a round-robin group', () => {
@@ -18,6 +24,17 @@ describe('Helpers', () => {
1824
assertRoundRobin([1, 2, 3, 4, 5], makeRoundRobinMatches([1, 2, 3, 4, 5]));
1925
assertRoundRobin([1, 2, 3, 4, 5, 6], makeRoundRobinMatches([1, 2, 3, 4, 5, 6]));
2026
});
27+
28+
it('should mirror the same round order for a double round-robin group', () => {
29+
assert.deepStrictEqual(makeRoundRobinMatches([1, 2, 3, 4], 'double'), [
30+
[[1, 4], [3, 2]],
31+
[[2, 4], [1, 3]],
32+
[[3, 4], [2, 1]],
33+
[[4, 1], [2, 3]],
34+
[[4, 2], [3, 1]],
35+
[[4, 3], [1, 2]],
36+
]);
37+
});
2138
});
2239

2340
describe('Seed ordering methods', () => {
@@ -150,6 +167,15 @@ describe('Helpers', () => {
150167
]);
151168
});
152169

170+
it('should make a snake ordering for uneven groups', () => {
171+
assert.deepStrictEqual(ordering['groups.seed_optimized']([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14], 4), [
172+
1, 8, 9, 14, // 1st group
173+
2, 7, 10, 13, // 2nd group
174+
3, 6, 11, // 3rd group
175+
4, 5, 12, // 4th group
176+
]);
177+
});
178+
153179
it('should make a bracket-optimized ordering for groups (8 seeds, 4 groups)', () => {
154180
const seeds = [1, 2, 3, 4, 5, 6, 7, 8];
155181
const result = ordering['groups.bracket_optimized'](seeds, 4);
@@ -221,6 +247,15 @@ describe('Helpers', () => {
221247
[4, 6, 9, 15],
222248
]);
223249
});
250+
251+
it('should make a bracket-optimized ordering for groups without missing seeds', () => {
252+
const seeds = Array.from({ length: 14 }, (_, i) => i + 1);
253+
const result = ordering['groups.bracket_optimized'](seeds, 4);
254+
255+
assert.strictEqual(result.length, 14);
256+
assert.isFalse(result.includes(undefined));
257+
assert.deepStrictEqual(makeGroups(result, 4).map(group => group.length), [4, 4, 3, 3]);
258+
});
224259
});
225260

226261
describe('Balance BYEs', () => {

test/round-robin.spec.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ const { Status } = require('brackets-model');
99
const storage = new JsonDatabase();
1010
const manager = new BracketsManager(storage);
1111

12+
const getGroupPositions = async () => {
13+
const groups = await storage.select('group');
14+
15+
return Promise.all(groups.map(async group => {
16+
const matches = await storage.select('match', { group_id: group.id });
17+
const positions = matches.flatMap(match => [match.opponent1?.position, match.opponent2?.position])
18+
.filter(position => typeof position === 'number');
19+
20+
return [...new Set(positions)].sort((a, b) => a - b);
21+
}));
22+
};
23+
1224
describe('Create a round-robin stage', () => {
1325

1426
beforeEach(() => {
@@ -75,6 +87,28 @@ describe('Create a round-robin stage', () => {
7587
}
7688
});
7789

90+
it('should preserve uneven manual seeding groups', async () => {
91+
const manualOrdering = [
92+
[1, 2, 3, 4],
93+
[5, 6, 7, 8],
94+
[9, 10, 11],
95+
[12, 13, 14],
96+
];
97+
98+
await manager.create.stage({
99+
name: 'Example',
100+
tournamentId: 0,
101+
type: 'round_robin',
102+
seeding: Array.from({ length: 14 }, (_, i) => `Team ${i + 1}`),
103+
settings: {
104+
groupCount: 4,
105+
manualOrdering,
106+
},
107+
});
108+
109+
assert.deepStrictEqual(await getGroupPositions(), manualOrdering);
110+
});
111+
78112
it('should throw if manual ordering has invalid counts', async () => {
79113
await assert.isRejected(manager.create.stage({
80114
name: 'Example',
@@ -134,6 +168,32 @@ describe('Create a round-robin stage', () => {
134168
assert.strictEqual((await storage.select('match')).length, 11);
135169
});
136170

171+
it('should balance group sizes when participant count is not divisible by group count', async () => {
172+
const seedOrderings = [
173+
undefined,
174+
'groups.effort_balanced',
175+
'groups.seed_optimized',
176+
'groups.bracket_optimized',
177+
];
178+
179+
for (const seedOrdering of seedOrderings) {
180+
storage.reset();
181+
182+
await manager.create.stage({
183+
name: 'Example',
184+
tournamentId: 0,
185+
type: 'round_robin',
186+
seeding: Array.from({ length: 14 }, (_, i) => `Team ${i + 1}`),
187+
settings: {
188+
groupCount: 4,
189+
...(seedOrdering ? { seedOrdering: [seedOrdering] } : {}),
190+
},
191+
});
192+
193+
assert.deepStrictEqual((await getGroupPositions()).map(group => group.length), [4, 4, 3, 3]);
194+
}
195+
});
196+
137197
it('should create a round-robin stage with to be determined participants', async () => {
138198
await manager.create.stage({
139199
name: 'Example',

0 commit comments

Comments
 (0)