@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,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
+ }