@opndev/tennis-match-center 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,174 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ import { SingleEliminationBracket } from './bracket-single-elimination.mjs'
6
+
7
+ const ROUND_ORDER = ['R256', 'R128', 'R64', 'R32', 'R16', 'QF', 'SF', 'F']
8
+
9
+ /**
10
+ * Gate 2's own auto-fill, factored out of <tennis-bracket-board> so it
11
+ * can run without a live element - a bulk "generate everything" pass
12
+ * (Competitions overview's "Generate all groups", which needs a
13
+ * bracket for every division right after its groups are seeded, not
14
+ * just the one being looked at in the Roster tab) calls this directly
15
+ * instead of driving the board headlessly.
16
+ *
17
+ * Takes the same `seeds` BracketController::index returns and produces
18
+ * the same `matches` shape <tennis-bracket-board>'s save() posts to
19
+ * BracketController::store - first round real, later rounds TBD
20
+ * shells. An empty seed list produces no matches at all, same as the
21
+ * board showing nothing to prefill.
22
+ *
23
+ * @param {Array<{team_id: number, tier: number, group_name: string}>} seeds
24
+ * @returns {Array<{round: string, home_team_id: ?number, visitor_team_id: ?number}>}
25
+ */
26
+ export function autoFillBracket(seeds) {
27
+ if (seeds.length === 0) {
28
+ return []
29
+ }
30
+
31
+ const pools = poolsFromSeeds(seeds)
32
+ // Sized to the real number of advancing teams, not padded to some
33
+ // minimum - see <tennis-bracket-board>'s own prefill().
34
+ const draw = Math.max(2, nextPowerOfTwo(seeds.length))
35
+
36
+ let round, rawMatches
37
+
38
+ if (draw < 8) {
39
+ const entrants = [...pools.seeded, ...pools.unseeded, ...pools.wildcards, ...pools.qualifiers]
40
+ const slots = standardSeedOrder(draw).map(seedNumber => entrants[seedNumber - 1] ?? 'Bye')
41
+
42
+ rawMatches = []
43
+ for (let i = 0; i < slots.length; i += 2) {
44
+ rawMatches.push(`${slots[i]} vs ${slots[i + 1]}`)
45
+ }
46
+ round = firstRoundForDraw(draw)
47
+ } else {
48
+ const bracket = new SingleEliminationBracket({
49
+ draw,
50
+ seeded: pools.seeded,
51
+ unseeded: pools.unseeded,
52
+ wildcards: pools.wildcards,
53
+ qualifiers: pools.qualifiers,
54
+ })
55
+
56
+ rawMatches = bracket.matches()
57
+ round = bracket.rounds()[0]
58
+ }
59
+
60
+ const firstRoundSlots = rawMatches.map(entry => {
61
+ const [home, visitor] = entry.split(' vs ')
62
+
63
+ return {
64
+ home_team_id: home === 'Bye' ? null : Number(home),
65
+ visitor_team_id: visitor === 'Bye' ? null : Number(visitor),
66
+ }
67
+ })
68
+
69
+ return buildMatches(round, firstRoundSlots)
70
+ }
71
+
72
+ /**
73
+ * Every later round as TBD shells too, not just the first - Planning
74
+ * and Scheduling both need to see the whole bracket in advance to
75
+ * reserve capacity for it, the entrants are unknown but the match
76
+ * count is not.
77
+ */
78
+ function buildMatches(round, firstRoundSlots) {
79
+ const firstRound = firstRoundSlots
80
+ .filter(slot => slot.home_team_id || slot.visitor_team_id)
81
+ .map(slot => ({
82
+ round,
83
+ home_team_id: slot.home_team_id,
84
+ visitor_team_id: slot.visitor_team_id,
85
+ }))
86
+
87
+ const laterRounds = []
88
+ let count = Math.max(1, Math.floor(firstRoundSlots.length / 2))
89
+
90
+ for (const laterRound of roundsFrom(round).slice(1)) {
91
+ for (let i = 0; i < count; i++) {
92
+ laterRounds.push({
93
+ round: laterRound,
94
+ home_team_id: null,
95
+ visitor_team_id: null,
96
+ })
97
+ }
98
+
99
+ if (laterRound === 'F') {
100
+ break
101
+ }
102
+
103
+ count = Math.max(1, Math.floor(count / 2))
104
+ }
105
+
106
+ return [...firstRound, ...laterRounds]
107
+ }
108
+
109
+ /**
110
+ * Buckets seeds by advancement tier (1st of each group, 2nd of each
111
+ * group, ...) into SingleEliminationBracket's four pools, so a group's
112
+ * own representatives are kept apart rather than merely ranked by
113
+ * overall strength - see <tennis-bracket-board>'s own copy of this
114
+ * doc comment for the full rationale.
115
+ */
116
+ function poolsFromSeeds(seeds) {
117
+ const tierNumbers = [...new Set(seeds.map(seed => seed.tier))].sort((a, b) => a - b)
118
+ const poolNames = ['seeded', 'unseeded', 'wildcards', 'qualifiers']
119
+
120
+ const pools = { seeded: [], unseeded: [], wildcards: [], qualifiers: [] }
121
+
122
+ tierNumbers.forEach((tier, tierIndex) => {
123
+ const ordered = seeds
124
+ .filter(seed => seed.tier === tier)
125
+ .sort((a, b) => a.group_name.localeCompare(b.group_name))
126
+ .map(seed => String(seed.team_id))
127
+
128
+ const rotated = tierIndex === 0
129
+ ? ordered
130
+ : rotate(ordered, Math.floor(ordered.length / 2))
131
+
132
+ const poolName = poolNames[tierIndex] ?? 'qualifiers'
133
+
134
+ pools[poolName].push(...rotated)
135
+ })
136
+
137
+ return pools
138
+ }
139
+
140
+ function rotate(array, offset) {
141
+ return [...array.slice(offset), ...array.slice(0, offset)]
142
+ }
143
+
144
+ function nextPowerOfTwo(n) {
145
+ let value = 1
146
+
147
+ while (value < n) {
148
+ value *= 2
149
+ }
150
+
151
+ return value
152
+ }
153
+
154
+ /**
155
+ * Standard tournament seeding order for a draw of n (a power of 2) -
156
+ * see <tennis-bracket-board>'s own copy of this doc comment.
157
+ */
158
+ function standardSeedOrder(n) {
159
+ if (n === 1) {
160
+ return [1]
161
+ }
162
+
163
+ return standardSeedOrder(n / 2).flatMap(seed => [seed, n + 1 - seed])
164
+ }
165
+
166
+ function firstRoundForDraw(draw) {
167
+ return ROUND_ORDER[ROUND_ORDER.length - Math.log2(draw)]
168
+ }
169
+
170
+ function roundsFrom(firstRound) {
171
+ const idx = ROUND_ORDER.indexOf(firstRound)
172
+
173
+ return idx === -1 ? [firstRound] : ROUND_ORDER.slice(idx)
174
+ }
@@ -0,0 +1,496 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
2
+ //
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+
5
+ /**
6
+ * Single-elimination bracket seeding, ported line-for-line from the Perl
7
+ * reference implementation named in spec.md:
8
+ * Games::Tournament::Format::Elimination::Single
9
+ * (opndev/games-tournament-format). Kept as its own module, independent
10
+ * of any Sugar component, because the seeding logic has nothing to do
11
+ * with rendering.
12
+ *
13
+ * Entries are plain strings ("Seed 1 vs Seed 8", "Seed 1 vs Bye") to stay
14
+ * a faithful, fixture-verified port of the Perl module first. Mapping
15
+ * those back to real team ids/objects is the caller's job.
16
+ */
17
+ export class SingleEliminationBracket {
18
+ /**
19
+ * @param {object} options
20
+ * @param {number} options.draw - power of two, minimum 8
21
+ * @param {string[]} [options.seeded]
22
+ * @param {string[]} [options.unseeded]
23
+ * @param {string[]} [options.wildcards]
24
+ * @param {string[]} [options.qualifiers]
25
+ * @param {boolean} [options.weakestSeed=true]
26
+ * @param {boolean} [options.weakestOpponent=true]
27
+ * @param {boolean} [options.wildcardAsSeed=false]
28
+ * @param {boolean} [options.qualifierAsSeed=false]
29
+ * @param {string[]} [options.order] - subset/reorder of
30
+ * ['unseeded', 'qualifier', 'wildcard']
31
+ */
32
+ constructor(options = {}) {
33
+ if (options.draw != null) {
34
+ if (!isPowerOfTwo(options.draw)) {
35
+ throw new Error(`Unable to set draw of ${options.draw}, not a power of 2`)
36
+ }
37
+
38
+ if (options.draw < 8) {
39
+ throw new Error('Unable to set draw with a size less than 8')
40
+ }
41
+ }
42
+
43
+ if (!('seeded' in options) && !('unseeded' in options)) {
44
+ throw new Error('No seeded or unseeded participants')
45
+ }
46
+
47
+ const draw = options.draw ?? 8
48
+ const defaultOrder = ['unseeded', 'qualifier', 'wildcard']
49
+ const requestedOrder = options.order ?? defaultOrder
50
+ const order = requestedOrder.filter(key => defaultOrder.includes(key))
51
+
52
+ this.draw = draw
53
+ this.weakestSeed = options.weakestSeed ?? true
54
+ this.weakestOpponent = options.weakestOpponent ?? true
55
+ this.wildcardAsSeed = options.wildcardAsSeed ?? false
56
+ this.qualifierAsSeed = options.qualifierAsSeed ?? false
57
+
58
+ this.seeded = []
59
+ this.unseeded = []
60
+ this.wildcards = []
61
+ this.qualifiers = []
62
+ this.order = [...order]
63
+ this.playerCount = 0
64
+
65
+ this.unseededIdx = 0
66
+ this.wildcardIdx = 0
67
+ this.qualifierIdx = 0
68
+
69
+ this.maxMatches = draw / 2
70
+ this.maxBracketMatches = this.maxMatches / 2
71
+
72
+ this._addSeeded(options.seeded ?? [])
73
+ this._addUnseeded(options.unseeded ?? [])
74
+ this._addQualifiers(options.qualifiers ?? [])
75
+ this._addWildcards(options.wildcards ?? [])
76
+
77
+ if (!this.playerCount) {
78
+ throw new Error('Cannot have a tourney without participants')
79
+ }
80
+
81
+ if (this.playerCount > draw) {
82
+ throw new Error('Cannot have a tourney with more players than the draw size!')
83
+ }
84
+
85
+ this._addByePlayers()
86
+
87
+ this._roundsCount = powerOfTwoExponent(draw)
88
+
89
+ let roundsArr = ['F', 'SF', 'QF']
90
+
91
+ if (this._roundsCount > 3) {
92
+ let p = 16
93
+
94
+ for (let i = 4; i <= this._roundsCount; i++) {
95
+ roundsArr.push(`R${p}`)
96
+ p *= 2
97
+ }
98
+ }
99
+
100
+ this._roundsArr = roundsArr.slice().reverse()
101
+
102
+ this._left = []
103
+ this._right = []
104
+ this._matches = []
105
+ this._seededMatches = []
106
+ this._unseededMatches = []
107
+
108
+ this._initSeededMatches()
109
+ this._bracketMatches()
110
+ this._addMatches()
111
+ }
112
+
113
+ // -- public API, mirroring the Perl module's methods --------------------
114
+
115
+ matches(side) {
116
+ if (!side) {
117
+ return [...this._matches]
118
+ }
119
+
120
+ if (['bottom', 'right'].includes(side)) {
121
+ return [...this._right]
122
+ }
123
+
124
+ if (['top', 'left'].includes(side)) {
125
+ return [...this._left]
126
+ }
127
+
128
+ return [...this._matches]
129
+ }
130
+
131
+ getPlayerCount() {
132
+ return this.playerCount
133
+ }
134
+
135
+ numberOfRounds() {
136
+ return this._roundsCount
137
+ }
138
+
139
+ getByes() {
140
+ return this.byesCount
141
+ }
142
+
143
+ rounds() {
144
+ return [...this._roundsArr]
145
+ }
146
+
147
+ round(ask) {
148
+ if (/^[0-9]$/.test(String(ask))) {
149
+ if (Number(ask) > this._roundsCount) {
150
+ throw new Error('Unable to ask for such a round')
151
+ }
152
+
153
+ return this._roundsArr[Number(ask) - 1]
154
+ }
155
+
156
+ const found = this._roundsArr.find(r => r === String(ask).toUpperCase())
157
+
158
+ if (found) {
159
+ return found
160
+ }
161
+
162
+ throw new Error('Unable to determine what you want')
163
+ }
164
+
165
+ games() {
166
+ const count = this.playerCount - 1
167
+
168
+ return Array.from({ length: Math.max(0, count) }, (_, i) => i + 1)
169
+ }
170
+
171
+ // -- construction helpers -------------------------------------------
172
+
173
+ _addUnseeded(items) {
174
+ this.unseeded.push(...items)
175
+
176
+ if (!this.order.includes('unseeded')) {
177
+ this.order.push('unseeded')
178
+ }
179
+
180
+ this.playerCount += items.length
181
+ }
182
+
183
+ _addSeeded(items) {
184
+ this.seeded.push(...items)
185
+ this.playerCount += items.length
186
+ }
187
+
188
+ _addWildcards(items) {
189
+ if (this.wildcardAsSeed) {
190
+ this._addSeeded(items)
191
+ return
192
+ }
193
+
194
+ this.wildcards.push(...items)
195
+
196
+ if (!this.order.includes('wildcard')) {
197
+ this.order.push('wildcard')
198
+ }
199
+
200
+ this.playerCount += items.length
201
+ }
202
+
203
+ _addQualifiers(items) {
204
+ if (this.qualifierAsSeed) {
205
+ this._addSeeded(items)
206
+ return
207
+ }
208
+
209
+ this.qualifiers.push(...items)
210
+
211
+ if (!this.order.includes('qualifier')) {
212
+ this.order.push('qualifier')
213
+ }
214
+
215
+ this.playerCount += items.length
216
+ }
217
+
218
+ _addByePlayers() {
219
+ // Perl has two variables named "byes": a scalar $byes (constant, this
220
+ // is it) and an array @byes (a shrinking countdown, that's
221
+ // _byesQueueLength below) - conflating them was the first bug this
222
+ // port's fixture diff against the real Perl module caught.
223
+ this.byesCount = this.draw - this.playerCount
224
+ this._byesQueueLength = this.byesCount
225
+ }
226
+
227
+ // -- seeding pipeline, in the same order as _init_seeded_matches -----
228
+
229
+ _initSeededMatches() {
230
+ this._addByeMatches()
231
+
232
+ for (const key of this.order) {
233
+ if (key === 'unseeded') this._addSeededMatches('unseeded', [...this.unseeded])
234
+ if (key === 'qualifier') this._addSeededMatches('qualifier', [...this.qualifiers])
235
+ if (key === 'wildcard') this._addSeededMatches('wildcard', [...this.wildcards])
236
+ }
237
+
238
+ const l = [...this.seeded]
239
+ let right = l.splice(this.maxMatches)
240
+
241
+ if (this.weakestSeed) {
242
+ right = right.reverse()
243
+ }
244
+
245
+ this._addSeededMatches('seeded', right)
246
+
247
+ this._matches = [...this._seededMatches]
248
+ }
249
+
250
+ _addByeMatches() {
251
+ if (!this.byesCount) {
252
+ return
253
+ }
254
+
255
+ let max = this.seeded.length - this.byesCount
256
+
257
+ if (max < 0) {
258
+ max *= -1
259
+
260
+ for (const key of this.order) {
261
+ const source = key === 'unseeded' ? this.unseeded
262
+ : key === 'wildcard' ? this.wildcards
263
+ : key === 'qualifier' ? this.qualifiers
264
+ : null
265
+
266
+ if (!source) continue
267
+
268
+ if (source.length >= max) {
269
+ const grab = source.splice(0, max)
270
+ this.seeded.push(...grab)
271
+ max = 0
272
+ }
273
+ else if (source.length < max) {
274
+ this.seeded.push(...source)
275
+ max -= source.length
276
+ source.length = 0
277
+ }
278
+ }
279
+ }
280
+
281
+ for (let i = 1; i <= this.byesCount; i++) {
282
+ if (this.seeded[i - 1] === undefined) break
283
+
284
+ this._seededMatches.push(`${this.seeded[i - 1]} vs Bye`)
285
+ this._byesQueueLength--
286
+ }
287
+ }
288
+
289
+ _addSeededMatches(src, source) {
290
+ if (!this.seeded.length) return
291
+ if (this._seededMatches.length === this.seeded.length) return
292
+
293
+ const idx = this._seededMatches.length
294
+ let max = this.seeded.length - this._seededMatches.length + idx
295
+
296
+ if (source.length < max) {
297
+ max = source.length
298
+ }
299
+
300
+ // How many of `source` this call actually consumed, so the next
301
+ // stage (_addUnseededMatches) knows to skip exactly that many
302
+ // before drawing from what's left. Recording the loop's own index
303
+ // instead undercounted by one whenever the loop broke out early
304
+ // (byes leaving fewer seeded slots than there were unseeded/
305
+ // wildcard/qualifier entrants to pair against them) - `i` names
306
+ // the entrant just consumed, not how many have been so far, so
307
+ // the next stage skipped one too few: it re-drew the entrant right
308
+ // after the one just placed (a real player entered twice) and
309
+ // never reached the last entrant at all (dropped from the draw
310
+ // entirely).
311
+ let consumed = 0
312
+
313
+ for (let i = 0; i < max; i++) {
314
+ const s = source.shift()
315
+
316
+ this._seededMatches.push(`${this.seeded[i + idx]} vs ${s}`)
317
+ consumed = i + 1
318
+
319
+ if (this._seededMatches.length === this.seeded.length) break
320
+ }
321
+
322
+ if (src === 'unseeded') this.unseededIdx = consumed
323
+ if (src === 'wildcard') this.wildcardIdx = consumed
324
+ if (src === 'qualifier') this.qualifierIdx = consumed
325
+ }
326
+
327
+ _addUnseededMatches(src, max, source) {
328
+ if (!max) return 0
329
+ if (!source.length) return 0
330
+
331
+ const idx = src === 'unseeded' ? this.unseededIdx
332
+ : src === 'wildcard' ? this.wildcardIdx
333
+ : this.qualifierIdx
334
+
335
+ source.splice(0, idx)
336
+
337
+ if (source.length > max) {
338
+ source.splice(max)
339
+ }
340
+
341
+ this._unseededMatches.push(...source)
342
+
343
+ if (src === 'unseeded') this.unseededIdx += source.length
344
+ if (src === 'wildcard') this.wildcardIdx += source.length
345
+ if (src === 'qualifier') this.qualifierIdx += source.length
346
+
347
+ return max - source.length
348
+ }
349
+
350
+ _addUnseededMatchesOpp(src, idx, source) {
351
+ if (!source.length) return idx
352
+
353
+ const max = this._unseededMatches.length - idx
354
+
355
+ if (!max) return idx
356
+
357
+ const s = src === 'unseeded' ? this.unseededIdx
358
+ : src === 'wildcard' ? this.wildcardIdx
359
+ : this.qualifierIdx
360
+
361
+ source.splice(0, s)
362
+
363
+ if (source.length > max) {
364
+ source.splice(max)
365
+ }
366
+
367
+ if (!source.length) return idx
368
+
369
+ for (const item of source) {
370
+ this._unseededMatches[idx] += ` vs ${item}`
371
+ idx++
372
+ }
373
+
374
+ if (src === 'unseeded') this.unseededIdx += source.length
375
+ if (src === 'wildcard') this.wildcardIdx += source.length
376
+ if (src === 'qualifier') this.qualifierIdx += source.length
377
+
378
+ return idx
379
+ }
380
+
381
+ _addMatches() {
382
+ if (this._matches.length === this.maxMatches) return
383
+
384
+ const mLeft = this.maxBracketMatches - this._left.length
385
+ const mRight = this.maxBracketMatches - this._right.length
386
+ let matchesToAdd = mLeft + mRight
387
+
388
+ for (const key of this.order) {
389
+ if (key === 'unseeded') matchesToAdd = this._addUnseededMatches(key, matchesToAdd, [...this.unseeded])
390
+ if (key === 'wildcard') matchesToAdd = this._addUnseededMatches(key, matchesToAdd, [...this.wildcards])
391
+ if (key === 'qualifier') matchesToAdd = this._addUnseededMatches(key, matchesToAdd, [...this.qualifiers])
392
+ }
393
+
394
+ let idx = 0
395
+ for (const key of this.order) {
396
+ if (key === 'unseeded') idx = this._addUnseededMatchesOpp(key, idx, [...this.unseeded])
397
+ if (key === 'wildcard') idx = this._addUnseededMatchesOpp(key, idx, [...this.wildcards])
398
+ if (key === 'qualifier') idx = this._addUnseededMatchesOpp(key, idx, [...this.qualifiers])
399
+ }
400
+
401
+ while (this._byesQueueLength > 0) {
402
+ this._byesQueueLength -= 2
403
+ this._unseededMatches.push('Bye vs Bye')
404
+ }
405
+
406
+ idx = 1
407
+ let s = this._left.length > this._right.length ? -1 : 1
408
+ let c = 0
409
+
410
+ while (this._unseededMatches.length) {
411
+ const match = this._unseededMatches.shift()
412
+ const source = s > 0 ? this._left : this._right
413
+
414
+ if (source.length) {
415
+ source.splice(idx, 0, match)
416
+ }
417
+ else {
418
+ source.push(match)
419
+ }
420
+
421
+ s *= -1
422
+
423
+ if (c % 2) {
424
+ idx *= -1
425
+ if (idx > 0) idx += 2
426
+ if (idx > this._left.length / 2) idx = 1
427
+ }
428
+
429
+ c++
430
+ }
431
+
432
+ this._matches = []
433
+ for (let i = 0; i < this._left.length; i++) {
434
+ this._matches.push(this._left[i], this._right[i])
435
+ }
436
+ }
437
+
438
+ _bracketMatches() {
439
+ while (this._matches.length) {
440
+ this._left.push(this._matches.shift())
441
+
442
+ if (this._matches.length) {
443
+ this._right.push(this._matches.shift())
444
+ }
445
+ }
446
+
447
+ if (this._left.length > 1 && this._left.length > this._right.length) {
448
+ this._right.push(this._left.pop())
449
+ }
450
+
451
+ this._left = this._shuffleBracket(this._left)
452
+ this._right = this._shuffleBracket(this._right)
453
+
454
+ for (let i = 0; i < this._left.length; i += 2) {
455
+ if (this._left[i]) this._matches.push(this._left[i])
456
+ if (this._right[i]) this._matches.push(this._right[i])
457
+ if (this._left[i + 1]) this._matches.push(this._left[i + 1])
458
+ if (this._right[i + 1]) this._matches.push(this._right[i + 1])
459
+ }
460
+ }
461
+
462
+ _shuffleBracket(bracket) {
463
+ bracket = [...bracket]
464
+
465
+ const l = []
466
+ const r = []
467
+
468
+ while (bracket.length) {
469
+ l.push(bracket.shift(), bracket.pop())
470
+ r.push(bracket.shift(), bracket.pop())
471
+
472
+ if (!this.weakestOpponent || !bracket.length) continue
473
+
474
+ const b = bracket.splice(Math.floor(bracket.length / 2))
475
+
476
+ bracket = [...bracket.reverse(), ...b.reverse()]
477
+ }
478
+
479
+ return [...l, ...r.reverse()].filter(v => v !== undefined)
480
+ }
481
+ }
482
+
483
+ function isPowerOfTwo(value) {
484
+ return (value & (value - 1)) === 0
485
+ }
486
+
487
+ function powerOfTwoExponent(value) {
488
+ let count = 0
489
+
490
+ while (value !== 1) {
491
+ value = value >> 1
492
+ count++
493
+ }
494
+
495
+ return count
496
+ }