@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,573 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesley@opndev.io>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { MATCH_MINUTES, REST_MINUTES } from './match-time.mjs'
|
|
6
|
+
import { SPLIT_SIZES } from './knockout-draw.mjs'
|
|
7
|
+
import { activePhase, activeSession } from './sessions.mjs'
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Which of two court blocks to prefer: the one standing closest
|
|
12
|
+
* together, then the one under lights, then the lower one.
|
|
13
|
+
*
|
|
14
|
+
* Light breaks a tie rather than deciding it - a round takes the lit
|
|
15
|
+
* courts where that costs it nothing, so an overrunning match still
|
|
16
|
+
* has somewhere to finish, but it does not climb the row to find them.
|
|
17
|
+
*
|
|
18
|
+
* @param {number[]} a
|
|
19
|
+
* @param {number[]} b
|
|
20
|
+
* @returns {boolean}
|
|
21
|
+
*/
|
|
22
|
+
function cheaperWindow(a, b) {
|
|
23
|
+
for (let i = 0; i < a.length; i++) {
|
|
24
|
+
if (a[i] !== b[i]) {
|
|
25
|
+
return a[i] < b[i]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The courts a round takes this slot, or nothing where it cannot have
|
|
34
|
+
* them.
|
|
35
|
+
*
|
|
36
|
+
* Identities are picked here rather than when the round was sized, so
|
|
37
|
+
* the lower courts go to the round closest to its Final. Feasibility
|
|
38
|
+
* was counted against the same pool, but a round needing several slots
|
|
39
|
+
* is fussier than one needing a single slot, so it can still come up
|
|
40
|
+
* short once a shallower round has taken its pick - in which case it
|
|
41
|
+
* waits rather than start somewhere it cannot finish.
|
|
42
|
+
*
|
|
43
|
+
* Of the court sets that would do, it takes the one standing closest
|
|
44
|
+
* together, and the lowest of those - a division's matches belong in
|
|
45
|
+
* one band of the row rather than scattered down it, and the rounds
|
|
46
|
+
* are handed their courts nearest-the-Final first, so the band order
|
|
47
|
+
* follows the draw's own order.
|
|
48
|
+
*
|
|
49
|
+
* @param {Object} item The draw and the cut it is taking.
|
|
50
|
+
* @param {Array} pool Courts still going in this slot.
|
|
51
|
+
* @param {PlanningDay} day
|
|
52
|
+
* @param {Object} where Its slots and where the run starts.
|
|
53
|
+
* @returns {Array} The courts held, or an empty list.
|
|
54
|
+
*/
|
|
55
|
+
function holdCourts(item, pool, day, where, row) {
|
|
56
|
+
if (item.held) {
|
|
57
|
+
const held = item.held.filter(court => pool.includes(court))
|
|
58
|
+
|
|
59
|
+
return held.length === 0 ? [] : held
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const fitting = day.fitting(pool, where, item.parts)
|
|
63
|
+
|
|
64
|
+
if (fitting.length < item.count) {
|
|
65
|
+
return []
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const at = court => row.indexOf(court)
|
|
69
|
+
const busy = new Set(row.filter(court => !pool.includes(court)).map(at))
|
|
70
|
+
|
|
71
|
+
let best = null
|
|
72
|
+
|
|
73
|
+
for (let start = 0; start + item.count <= fitting.length; start++) {
|
|
74
|
+
const window = fitting.slice(start, start + item.count)
|
|
75
|
+
const cost = [
|
|
76
|
+
holesWith(busy, window.map(at), row.length),
|
|
77
|
+
at(window[window.length - 1]) - at(window[0]),
|
|
78
|
+
at(window[0]),
|
|
79
|
+
window.filter(court => !court.lit).length,
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
if (best === null || cheaperWindow(cost, best.cost)) {
|
|
83
|
+
best = { window, cost }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return best.window
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* How many holes the row would have with these courts taken as well.
|
|
92
|
+
*
|
|
93
|
+
* A hole is a run of unused courts with play on both sides of it -
|
|
94
|
+
* "not yes, yes, no, yes; it should be yes, yes, yes, no". Courts
|
|
95
|
+
* idle at either end of the row are not holes, however many there are:
|
|
96
|
+
* a knockout round is half the one before it, so the tail empties out
|
|
97
|
+
* by design.
|
|
98
|
+
*
|
|
99
|
+
* @param {Set<number>} busy Positions already in use this slot.
|
|
100
|
+
* @param {number[]} taking Positions this round would take.
|
|
101
|
+
* @param {number} width Courts in the row.
|
|
102
|
+
* @returns {number}
|
|
103
|
+
*/
|
|
104
|
+
export function holesWith(busy, taking, width) {
|
|
105
|
+
const used = new Set([...busy, ...taking])
|
|
106
|
+
|
|
107
|
+
if (used.size === 0) {
|
|
108
|
+
return 0
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let holes = 0
|
|
112
|
+
let open = false
|
|
113
|
+
let seen = false
|
|
114
|
+
|
|
115
|
+
for (let position = 0; position < width; position++) {
|
|
116
|
+
if (used.has(position)) {
|
|
117
|
+
if (open) {
|
|
118
|
+
holes += 1
|
|
119
|
+
open = false
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
seen = true
|
|
123
|
+
} else if (seen) {
|
|
124
|
+
open = true
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return holes
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Books a round onto its courts, part by part, and says when the
|
|
133
|
+
* division is next free.
|
|
134
|
+
*
|
|
135
|
+
* The courts are recorded on the unit the first time, so whatever it
|
|
136
|
+
* starts on it finishes on - re-picking each slot is what had a round
|
|
137
|
+
* open on C10/C11 and close on C14/C15 an hour later. A round too wide
|
|
138
|
+
* for the day to finish (its spill) pins nothing, there being no set
|
|
139
|
+
* of courts it could finish on.
|
|
140
|
+
*
|
|
141
|
+
* A split is its own rest: the half that played first has sat out
|
|
142
|
+
* every band the second half took, so no rest slot is charged on top.
|
|
143
|
+
*
|
|
144
|
+
* @param {Object} item The draw and the cut it is taking.
|
|
145
|
+
* @param {Array} held The courts it plays on.
|
|
146
|
+
* @param {Object} day The competition, its slots and the rest it owes.
|
|
147
|
+
* @param {PlanningDay} day The day it is booked on.
|
|
148
|
+
* @param {Object} where The competition, its slots and the rest owed.
|
|
149
|
+
*/
|
|
150
|
+
function placeRound(item, held, day, where) {
|
|
151
|
+
const { draw, parts } = item
|
|
152
|
+
const { competition, own, slotIndex, restSlots } = where
|
|
153
|
+
const width = draw.width
|
|
154
|
+
const already = draw.placedInRound
|
|
155
|
+
const round = draw.round.round
|
|
156
|
+
|
|
157
|
+
if (!item.spill) {
|
|
158
|
+
draw.pin(held)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let placed = 0
|
|
162
|
+
|
|
163
|
+
for (let part = 0; part < parts && already + placed < width; part++) {
|
|
164
|
+
const partSlot = own[slotIndex + part]
|
|
165
|
+
|
|
166
|
+
for (const court of held) {
|
|
167
|
+
if (already + placed >= width) {
|
|
168
|
+
break
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
day.place({
|
|
172
|
+
competition_id: competition.id,
|
|
173
|
+
division_id: draw.divisionId,
|
|
174
|
+
division_name: draw.divisionName,
|
|
175
|
+
division_gender: draw.divisionGender,
|
|
176
|
+
group_id: null,
|
|
177
|
+
group_name: null,
|
|
178
|
+
phase: 'knockout',
|
|
179
|
+
round,
|
|
180
|
+
match_slot: already + placed,
|
|
181
|
+
court_id: court.id,
|
|
182
|
+
date: day.date,
|
|
183
|
+
slot: partSlot.key,
|
|
184
|
+
}, partSlot)
|
|
185
|
+
|
|
186
|
+
placed += 1
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
draw.book({ count: placed, parts, slotIndex, restSlots })
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* How many whole slots of rest a division is owed between two of its
|
|
195
|
+
* rounds, beyond the slot it is playing in.
|
|
196
|
+
*
|
|
197
|
+
* A slot already at least a match plus a rest long (Team Cup's
|
|
198
|
+
* 90-minute tie: 45 to play plus the 45 owed) owes nothing more, and
|
|
199
|
+
* the next round can use the very next slot. A 60-minute doubles slot
|
|
200
|
+
* covers the match only and still owes 30 of the 45, which a plain
|
|
201
|
+
* floor(45 / 60) would lose entirely - rolling the next round onto the
|
|
202
|
+
* same courts with no break at all.
|
|
203
|
+
*
|
|
204
|
+
* @param {number} slotLength Minutes the competition's slot runs.
|
|
205
|
+
* @returns {number}
|
|
206
|
+
*/
|
|
207
|
+
function restSlotsFor(slotLength) {
|
|
208
|
+
const owed = Math.max(0, MATCH_MINUTES + REST_MINUTES - slotLength)
|
|
209
|
+
|
|
210
|
+
return Math.ceil(owed / slotLength)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Which of exactly two divisions may take this slot.
|
|
215
|
+
*
|
|
216
|
+
* Two divisions (Masters Men and Women, most often) share their courts
|
|
217
|
+
* turn by turn rather than both grabbing their own the same slot - one
|
|
218
|
+
* match at a time reads as a single continuous draw rather than two
|
|
219
|
+
* competing for attention side by side. Three or more fill a slot with
|
|
220
|
+
* everyone ready, there being no sensible "whose turn" with that many.
|
|
221
|
+
*
|
|
222
|
+
* Resting or already finished hands the slot to the other rather than
|
|
223
|
+
* sitting idle waiting for a turn nobody can take.
|
|
224
|
+
*
|
|
225
|
+
* @param {Array} units Both divisions, in their fixed order.
|
|
226
|
+
* @param {Array} ready Those that could go on now.
|
|
227
|
+
* @param {number} turnIndex Whose turn it is.
|
|
228
|
+
* @returns {Array} The one draw that may go on, or nothing.
|
|
229
|
+
*/
|
|
230
|
+
function whoseTurn(units, ready, turnIndex) {
|
|
231
|
+
const turn = ready.includes(units[turnIndex])
|
|
232
|
+
? units[turnIndex]
|
|
233
|
+
: units[(turnIndex + 1) % 2]
|
|
234
|
+
|
|
235
|
+
return ready.includes(turn) ? [turn] : []
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Fills the knockout day slot by slot.
|
|
240
|
+
*
|
|
241
|
+
* Each division's rounds are a queue. A round becomes eligible once
|
|
242
|
+
* the division has had its rest, and the slot is then filled with
|
|
243
|
+
* whichever eligible rounds fit - widest round first, and within a
|
|
244
|
+
* round the weakest field first, so the beginners open the day and
|
|
245
|
+
* the finals close it.
|
|
246
|
+
*
|
|
247
|
+
* Laying the day out as strict round columns (every R32 before any
|
|
248
|
+
* R16) was too rigid: it left the whole grid idle while one division
|
|
249
|
+
* rested, when a division that had already rested could have played
|
|
250
|
+
* its next round alongside someone else's current one.
|
|
251
|
+
*
|
|
252
|
+
* A round goes on whole where the room is there, and is otherwise cut
|
|
253
|
+
* the way the bracket cuts - see SPLIT_SIZES above.
|
|
254
|
+
*
|
|
255
|
+
* @param {PlanningDay} day The date being filled - its courts, slots,
|
|
256
|
+
* occupancy and the plan the cells go into.
|
|
257
|
+
* @param {Object} params
|
|
258
|
+
* @param {Array} params.competitions In priority order.
|
|
259
|
+
* @param {Array<KnockoutDraw>} params.draws Every draw in the event;
|
|
260
|
+
* each competition takes its own.
|
|
261
|
+
* @param {Map<number, Array>} params.sessions Each competition's
|
|
262
|
+
* sessions, in order.
|
|
263
|
+
* @param {boolean} params.split Whether a round may be cut at all.
|
|
264
|
+
*/
|
|
265
|
+
export function assignKnockout(day, { competitions, draws, sessions, split }) {
|
|
266
|
+
const { date } = day
|
|
267
|
+
|
|
268
|
+
for (const competition of competitions) {
|
|
269
|
+
const own = day.slotsFor(competition.id)
|
|
270
|
+
const ownSessions = sessions.get(competition.id) ?? []
|
|
271
|
+
|
|
272
|
+
const ownSession = activeSession(ownSessions, date)
|
|
273
|
+
const restSlots = restSlotsFor(ownSession?.playtime ?? MATCH_MINUTES)
|
|
274
|
+
|
|
275
|
+
const firstSlot = own.findIndex(slot =>
|
|
276
|
+
(slot.phase ?? activePhase(ownSessions, date, slot.key)) === 'knockout'
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
if (firstSlot === -1) {
|
|
280
|
+
continue
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
for (const pro of [true, false]) {
|
|
284
|
+
const lane = { pro, courts: day.courtsFor(pro) }
|
|
285
|
+
const units = draws.filter(draw =>
|
|
286
|
+
draw.competition.id === competition.id && draw.isPro === pro
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
if (units.length === 0) {
|
|
290
|
+
continue
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
let turnIndex = 0
|
|
294
|
+
|
|
295
|
+
for (let slotIndex = firstSlot; slotIndex < own.length; slotIndex++) {
|
|
296
|
+
const slot = own[slotIndex]
|
|
297
|
+
|
|
298
|
+
/** The lane's courts with nothing on them in this slot. */
|
|
299
|
+
function free() {
|
|
300
|
+
return day.free(lane.courts, slot)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (free().length === 0) {
|
|
304
|
+
continue
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let ready = units
|
|
308
|
+
.filter(draw => draw.readyAt(slotIndex))
|
|
309
|
+
.sort((a, b) => (b.width - a.width) || (b.level - a.level))
|
|
310
|
+
|
|
311
|
+
if (ready.length === 0) {
|
|
312
|
+
continue
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (units.length === 2) {
|
|
316
|
+
ready = whoseTurn(units, ready, turnIndex)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (ready.length === 0) {
|
|
320
|
+
continue
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const placedHere = []
|
|
324
|
+
|
|
325
|
+
const fittingCache = new Map()
|
|
326
|
+
const where = { slots: own, slotIndex }
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The courts that could hold every part of a run this long,
|
|
330
|
+
* memoised for the slot.
|
|
331
|
+
*
|
|
332
|
+
* @param {number} parts Consecutive slots the run would take.
|
|
333
|
+
* @returns {Array} Courts free for all of them.
|
|
334
|
+
*/
|
|
335
|
+
function fittingCourts(parts) {
|
|
336
|
+
if (!fittingCache.has(parts)) {
|
|
337
|
+
fittingCache.set(parts, day.fitting(free(), where, parts))
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return fittingCache.get(parts)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Every way this round could go on in this slot, cheapest cut
|
|
345
|
+
* first. The band picks between them across divisions rather
|
|
346
|
+
* than each division taking the best it can see and leaving the
|
|
347
|
+
* next one the scraps.
|
|
348
|
+
*
|
|
349
|
+
* A round already under way keeps the courts it started on: they
|
|
350
|
+
* were decided when it started and it finishes on them, whatever
|
|
351
|
+
* of them is free now being all it may have.
|
|
352
|
+
*
|
|
353
|
+
* @param {KnockoutDraw} draw
|
|
354
|
+
* @param {Object} [options]
|
|
355
|
+
* @param {boolean} [options.forceSplit] Cut it even where the
|
|
356
|
+
* caller would rather have whole rounds - the band would
|
|
357
|
+
* otherwise stand empty.
|
|
358
|
+
* @returns {Array<{parts: number, count: number}>}
|
|
359
|
+
*/
|
|
360
|
+
function optionsFor(draw, { forceSplit = false } = {}) {
|
|
361
|
+
const { width, remaining } = draw
|
|
362
|
+
|
|
363
|
+
if (remaining <= 0) {
|
|
364
|
+
return []
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (draw.roundCourts) {
|
|
368
|
+
const held = fittingCourts(1)
|
|
369
|
+
.filter(court => draw.roundCourts.includes(court.id))
|
|
370
|
+
.slice(0, remaining)
|
|
371
|
+
|
|
372
|
+
if (held.length === 0) {
|
|
373
|
+
return []
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return [{ parts: 1, count: held.length, held }]
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const cutting = split || forceSplit
|
|
380
|
+
const slotsLeft = own.length - slotIndex
|
|
381
|
+
const usable = day.usable(lane.courts, slot).length
|
|
382
|
+
|
|
383
|
+
const options = draw.cuts(cutting)
|
|
384
|
+
.filter(cut => fittingCourts(cut.parts).length >= cut.count)
|
|
385
|
+
|
|
386
|
+
if (options.length > 0 || !cutting) {
|
|
387
|
+
return options
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (width <= usable * slotsLeft) {
|
|
391
|
+
return []
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const spill = Math.min(remaining, fittingCourts(1).length)
|
|
395
|
+
|
|
396
|
+
return spill > 0 ? [{ parts: 1, count: spill, spill: true }] : []
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Whether the courts exist for a set of choices. A court free
|
|
401
|
+
* for four slots running is free for two, so the deeper cuts are
|
|
402
|
+
* checked first and everything shallower shares what is left.
|
|
403
|
+
*/
|
|
404
|
+
function roomFor(choices) {
|
|
405
|
+
return SPLIT_SIZES.every(depth => {
|
|
406
|
+
const need = choices
|
|
407
|
+
.filter(choice => choice.parts >= depth)
|
|
408
|
+
.reduce((total, choice) => total + choice.count, 0)
|
|
409
|
+
|
|
410
|
+
return need <= fittingCourts(depth).length
|
|
411
|
+
})
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Whether this band leaves the draw reading forwards. A later
|
|
416
|
+
* round never goes on while an earlier one is still waiting for
|
|
417
|
+
* the room to go on whole, or one division would be playing
|
|
418
|
+
* quarters while another has not had its sixteenths.
|
|
419
|
+
*/
|
|
420
|
+
function inOrder(taken, waiting) {
|
|
421
|
+
const held = Math.max(
|
|
422
|
+
...waiting.map(draw => draw.roundsRemaining), -Infinity
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
return taken.every(({ draw }) => draw.roundsRemaining >= held)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Whether this choice abandons a split the division is already
|
|
430
|
+
* in. Its second half is a round behind its first all the way
|
|
431
|
+
* down, and going back to whole rounds would put the two halves
|
|
432
|
+
* on court together. The Final ends it: a semi split 1/1 still
|
|
433
|
+
* leads to one Final.
|
|
434
|
+
*/
|
|
435
|
+
function breaksStride({ draw, choice }) {
|
|
436
|
+
return draw.breaksStride(choice.parts)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* What a band costs, compared term by term, lowest winning:
|
|
441
|
+
* divisions breaking stride, then courts left idle, then the
|
|
442
|
+
* deepest cut anyone takes, then the number of cuts.
|
|
443
|
+
*
|
|
444
|
+
* @returns {number[]}
|
|
445
|
+
*/
|
|
446
|
+
function cost(taken) {
|
|
447
|
+
return [
|
|
448
|
+
taken.filter(breaksStride).length,
|
|
449
|
+
-taken.reduce((total, { choice }) => total + choice.count, 0),
|
|
450
|
+
Math.max(...taken.map(({ choice }) => choice.parts), 0),
|
|
451
|
+
taken.reduce((total, { choice }) => total + choice.parts, 0),
|
|
452
|
+
]
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function cheaper(a, b) {
|
|
456
|
+
const left = cost(a)
|
|
457
|
+
const right = cost(b)
|
|
458
|
+
|
|
459
|
+
for (let i = 0; i < left.length; i++) {
|
|
460
|
+
if (left[i] !== right[i]) {
|
|
461
|
+
return left[i] < right[i]
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return false
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* The cheapest set of rounds this band can hold, over every
|
|
470
|
+
* combination of the ready divisions and their legal cuts.
|
|
471
|
+
*
|
|
472
|
+
* @returns {Array<{draw: Object, choice: Object}>} Empty where
|
|
473
|
+
* nothing can go on at all.
|
|
474
|
+
*/
|
|
475
|
+
function bandChoice({ forceSplit = false } = {}) {
|
|
476
|
+
const offered = ready
|
|
477
|
+
.map(draw => ({ draw, options: optionsFor(draw, { forceSplit }) }))
|
|
478
|
+
|
|
479
|
+
const candidates = offered.filter(one => one.options.length > 0)
|
|
480
|
+
|
|
481
|
+
// A round with no cut that fits anywhere is waiting for room
|
|
482
|
+
// just as surely as one that was passed over, and a later
|
|
483
|
+
// round must not go on ahead of it. Leaving these out of the
|
|
484
|
+
// reckoning is what let a QF open before another division's
|
|
485
|
+
// R16 had started at all.
|
|
486
|
+
const stuck = offered
|
|
487
|
+
.filter(one => one.options.length === 0)
|
|
488
|
+
.map(one => one.draw)
|
|
489
|
+
|
|
490
|
+
let best = null
|
|
491
|
+
|
|
492
|
+
function walk(index, taken) {
|
|
493
|
+
if (index === candidates.length) {
|
|
494
|
+
if (taken.length === 0) {
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const waiting = candidates
|
|
499
|
+
.filter(candidate => !taken.some(
|
|
500
|
+
({ draw }) => draw === candidate.draw
|
|
501
|
+
))
|
|
502
|
+
.map(candidate => candidate.draw)
|
|
503
|
+
.concat(stuck)
|
|
504
|
+
|
|
505
|
+
const choices = taken.map(({ choice }) => choice)
|
|
506
|
+
|
|
507
|
+
if (!roomFor(choices) || !inOrder(taken, waiting)) {
|
|
508
|
+
return
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (best === null || cheaper(taken, best)) {
|
|
512
|
+
best = taken
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const { draw, options } = candidates[index]
|
|
519
|
+
|
|
520
|
+
for (const choice of options) {
|
|
521
|
+
walk(index + 1, [...taken, { draw, choice }])
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
walk(index + 1, taken)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
walk(0, [])
|
|
528
|
+
|
|
529
|
+
return best ?? []
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
for (const { draw, choice } of bandChoice()) {
|
|
533
|
+
placedHere.push({ draw, ...choice })
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (placedHere.length === 0) {
|
|
537
|
+
for (const { draw, choice } of bandChoice({ forceSplit: true })) {
|
|
538
|
+
placedHere.push({ draw, ...choice })
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (placedHere.length === 0) {
|
|
543
|
+
continue
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
placedHere.sort((a, b) =>
|
|
547
|
+
(a.draw.level - b.draw.level) ||
|
|
548
|
+
(a.draw.roundsRemaining - b.draw.roundsRemaining)
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
let pool = free()
|
|
552
|
+
|
|
553
|
+
for (const item of placedHere) {
|
|
554
|
+
const held = holdCourts(item, pool, day, where, lane.courts)
|
|
555
|
+
|
|
556
|
+
if (held.length === 0) {
|
|
557
|
+
continue
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
pool = pool.filter(court => !held.includes(court))
|
|
561
|
+
|
|
562
|
+
placeRound(item, held, day, {
|
|
563
|
+
competition, own, slotIndex, restSlots,
|
|
564
|
+
})
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (units.length === 2 && placedHere.length > 0) {
|
|
568
|
+
turnIndex = units.indexOf(placedHere[0].draw) === 0 ? 1 : 0
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|