@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.
- package/Changes +5 -0
- package/LICENSE +235 -0
- package/README.md +218 -0
- package/lib/auto-planner.mjs +1297 -0
- package/lib/bracket-autofill.mjs +174 -0
- package/lib/bracket-single-elimination.mjs +496 -0
- package/lib/busy-button.mjs +58 -0
- package/lib/components/bracket-board.mjs +586 -0
- package/lib/components/planning-board.mjs +1179 -0
- package/lib/components/roster-board.mjs +273 -0
- package/lib/components/roster-team.mjs +92 -0
- package/lib/components/schedule-board.mjs +1193 -0
- package/lib/court-bands.mjs +185 -0
- package/lib/court-runs.mjs +76 -0
- package/lib/day-rows.mjs +116 -0
- package/lib/division-hues.mjs +52 -0
- package/lib/five-team-planner.mjs +21 -0
- package/lib/flags.mjs +60 -0
- package/lib/four-team-planner.mjs +216 -0
- package/lib/group-schedule.mjs +175 -0
- package/lib/index.mjs +9 -0
- package/lib/knockout-draw.mjs +209 -0
- package/lib/knockout-planner.mjs +573 -0
- package/lib/knockout-waves.mjs +156 -0
- package/lib/lighting.mjs +50 -0
- package/lib/match-time.mjs +13 -0
- package/lib/occupancy.mjs +74 -0
- package/lib/paired-court-planner.mjs +236 -0
- package/lib/plan-placement.mjs +219 -0
- package/lib/plan.mjs +133 -0
- package/lib/planning-day.mjs +177 -0
- package/lib/roster-seeding.mjs +65 -0
- package/lib/run-queue-planner.mjs +603 -0
- package/lib/sessions.mjs +48 -0
- package/lib/three-team-planner.mjs +122 -0
- package/lib/webcomponents.mjs +25 -0
- package/opndev-tennis-match-center-0.0.1.tgz +0 -0
- package/package.json +39 -0
|
@@ -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
|
+
}
|