@opndev/tennis-match-center 0.0.2 → 0.0.4

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.
@@ -6,12 +6,20 @@ import { bandDistance, computeSectionBands, sectionsFor } from './court-bands.mj
6
6
  import { groupRuns } from './group-schedule.mjs'
7
7
 
8
8
  /**
9
- * Where a group should land the day after playing a given section:
10
- * S1 to S3, S2 to S4, S3 to S1, S4 to S2 - prio.pl's rotation. A
11
- * group that played late one day plays early the next and the other
12
- * way round, so nobody gets two late finishes or two early starts.
9
+ * Where a group should land the day after playing a given section: two
10
+ * sections on, wrapping round. With four that is S1 to S3, S2 to S4, S3
11
+ * to S1 and S4 to S2 - prio.pl's rotation, where a group that played
12
+ * late one day plays early the next. With three it is S1 to S3, S2 to
13
+ * S1 and S3 to S2, so nobody plays the same block twice running and
14
+ * nobody who finished late starts early.
15
+ *
16
+ * @param {number} previous The section it played last.
17
+ * @param {number} sectionCount
18
+ * @returns {?number} null where there are too few sections to rotate.
13
19
  */
14
- const SECTION_OFFSET = { 0: 2, 1: 3, 2: 0, 3: 1 }
20
+ function rotationTarget(previous, sectionCount) {
21
+ return sectionCount >= 3 ? (previous + 2) % sectionCount : null
22
+ }
15
23
 
16
24
  // Mirrors GameCounter::GAMES_PER_GROUP server-side: how many matches a
17
25
  // full group of this size plays. Used only to report a group with no
@@ -21,6 +29,102 @@ const GAMES_PER_POOL_SIZE = { 3: 3, 4: 6, 5: 10 }
21
29
 
22
30
  const queueKey = (competitionId, date) => `${competitionId}:${date}`
23
31
 
32
+ /**
33
+ * What is left of a run once the fixtures already in the plan are
34
+ * taken out.
35
+ *
36
+ * @param {{positions: Array<?number>, indices: number[]}} run
37
+ * @param {number} groupId
38
+ * @param {?Function} done (groupId, index) => whether it is in the plan
39
+ * @returns {?Object} the run, shortened, or null when nothing is left
40
+ */
41
+ export function remainderOf(run, groupId, done) {
42
+ if (done === null) {
43
+ return run
44
+ }
45
+
46
+ const owed = run.positions.filter(
47
+ position => position !== null && !done(groupId, position)
48
+ )
49
+
50
+ if (owed.length === 0) {
51
+ return null
52
+ }
53
+
54
+ if (owed.length === run.positions.filter(position => position !== null).length) {
55
+ return run
56
+ }
57
+
58
+ return { ...run, positions: owed, indices: owed }
59
+ }
60
+
61
+ /**
62
+ * How a section can be shared when courts are scarce.
63
+ *
64
+ * A run is one group on one court for its whole stretch. When courts
65
+ * drop out part way through a section, only the courts that last it
66
+ * can host a run, and the rest of the room goes spare - the front of
67
+ * the section has every court, the back only some. Whatever the reason
68
+ * a court drops out, the answer is the same: a group plays the front of
69
+ * its run on a court of its own while that court exists, and the back
70
+ * of it on one of the courts that remain, which two groups share by
71
+ * taking turns. Nobody waits longer than a match for their next.
72
+ *
73
+ * The section is then as long as the front plus two backs.
74
+ *
75
+ * Only the last section that loses courts is shared, and only when
76
+ * sharing holds more groups than whole runs do.
77
+ *
78
+ * @param {Object} params
79
+ * @param {Array} params.own The competition's slots that day.
80
+ * @param {Array} params.sections see sectionsFor
81
+ * @param {number} params.runLength
82
+ * @param {Array} params.courts
83
+ * @param {Function} params.usable (court, slot) => boolean
84
+ * @returns {?{index: number, first: number, front: number, back: number,
85
+ * length: number, capacity: number}}
86
+ */
87
+ export function sharedSectionFor({ own, sections, runLength, courts, usable }) {
88
+ for (let index = sections.length - 1; index >= 0; index--) {
89
+ const first = index * runLength
90
+ const open = courts.filter(court => usable(court, own[first]))
91
+
92
+ let front = 0
93
+
94
+ while (
95
+ front < runLength
96
+ && first + front < own.length
97
+ && open.every(court => usable(court, own[first + front]))
98
+ ) {
99
+ front++
100
+ }
101
+
102
+ // Nothing is lost during the run, or nothing is there to start with.
103
+ if (front === 0 || front === runLength) {
104
+ continue
105
+ }
106
+
107
+ const back = runLength - front
108
+ const length = front + 2 * back
109
+
110
+ if (first + length > own.length) {
111
+ continue
112
+ }
113
+
114
+ const remaining = own.slice(first + front, first + length)
115
+ const lasting = open.filter(court => remaining.every(slot => usable(court, slot)))
116
+ const capacity = Math.min(open.length, lasting.length * 2)
117
+
118
+ if (capacity <= sections[index].capacity) {
119
+ continue
120
+ }
121
+
122
+ return { index, first, front, back, length, capacity }
123
+ }
124
+
125
+ return null
126
+ }
127
+
24
128
  /**
25
129
  * Group play for any shape whose groups run their whole round robin as
26
130
  * one unbroken stretch on one court - 5-team's ten matches over two
@@ -63,6 +167,7 @@ export function makeRunQueuePlanner() {
63
167
  const totalRunsByDivision = new Map()
64
168
 
65
169
  const nightSections = new Set()
170
+ const sharedByKey = new Map()
66
171
  const runLengthByDay = new Map()
67
172
 
68
173
  /**
@@ -91,7 +196,7 @@ export function makeRunQueuePlanner() {
91
196
  * @returns {Array<{date: string, count: number}>} the capacity the
92
197
  * caller still has to account for, per date.
93
198
  */
94
- function enqueue({ competition, group, dates, asFull = false }) {
199
+ function enqueue({ competition, group, dates, asFull = false, done = null }) {
95
200
  const teams = group.team_ids ?? []
96
201
  const size = group.pool_size ?? teams.length
97
202
 
@@ -139,7 +244,16 @@ export function makeRunQueuePlanner() {
139
244
  return needed
140
245
  }
141
246
 
142
- for (const run of runs) {
247
+ for (const template of runs) {
248
+ // What is already in the plan is not owed again. A run with
249
+ // nothing left is dropped, and one part done is what remains of
250
+ // it, packed - on the day the template gave it.
251
+ const run = remainderOf(template, group.id, done)
252
+
253
+ if (run === null) {
254
+ continue
255
+ }
256
+
143
257
  // A template that spans two days needs two days to span. Where
144
258
  // the competition has only one, the group plays its whole round
145
259
  // robin that day rather than losing the half with nowhere to go
@@ -198,7 +312,7 @@ export function makeRunQueuePlanner() {
198
312
  * @param {Array} params.courts The courts these runs may use.
199
313
  * @param {Function} params.usable (court, slot) => boolean
200
314
  */
201
- function prepare({ slotsFor, courts, usable }) {
315
+ function prepare({ slotsFor, courts, usable, sharing = false }) {
202
316
  for (const [key, queue] of groupRunQueue) {
203
317
  const date = key.slice(key.indexOf(':') + 1)
204
318
  const order = []
@@ -241,6 +355,18 @@ export function makeRunQueuePlanner() {
241
355
 
242
356
  const capacities = sections.map(section => section.capacity)
243
357
 
358
+ // Where courts are the limit, the section that loses courts part
359
+ // way through may hold more groups than it has courts for the
360
+ // whole run - see sharedSectionFor.
361
+ const shared = sharing
362
+ ? sharedSectionFor({ own, sections, runLength, courts, usable })
363
+ : null
364
+
365
+ if (shared) {
366
+ capacities[shared.index] = shared.capacity
367
+ sharedByKey.set(key, shared)
368
+ }
369
+
244
370
  for (const section of sections) {
245
371
  if (section.night) {
246
372
  nightSections.add(`${key}:${section.index}`)
@@ -261,10 +387,16 @@ export function makeRunQueuePlanner() {
261
387
  // The quota/band math itself is shared with every other planner
262
388
  // shape - see court-bands.mjs. Only the run-queue bookkeeping
263
389
  // (sections, lights, offsets) stays local to this planner.
390
+ const dates = [...groupRunQueue.keys()]
391
+ .filter(other => other.startsWith(`${competitionId}:`))
392
+ .map(other => other.slice(other.indexOf(':') + 1))
393
+ .sort()
394
+
264
395
  const { quotasBySection, bandsBySection } = computeSectionBands({
265
396
  divisions,
266
397
  totals,
267
398
  capacities,
399
+ rotation: dates.indexOf(date),
268
400
  })
269
401
 
270
402
  for (let section = 0; section < sectionCount; section++) {
@@ -304,7 +436,10 @@ export function makeRunQueuePlanner() {
304
436
  return sectionCountByDate.get(date) ?? 1
305
437
  }
306
438
 
307
- function pick(competitionId, date, sectionIndex, court, { proCourt = false } = {}) {
439
+ function pick(
440
+ competitionId, date, sectionIndex, court,
441
+ { proCourt = false, strictQuota = false } = {}
442
+ ) {
308
443
  const queue = groupRunQueue.get(queueKey(competitionId, date))
309
444
 
310
445
  if (!queue || queue.length === 0) {
@@ -425,7 +560,8 @@ export function makeRunQueuePlanner() {
425
560
  // it for every group, so it only orders the choice.
426
561
  const wanted = owed.filter(entry => {
427
562
  const previous = previousSection(queue[entry.index].groupId, date)
428
- return previous !== null && SECTION_OFFSET[previous] === sectionIndex
563
+ return previous !== null
564
+ && rotationTarget(previous, sectionCountFor(date)) === sectionIndex
429
565
  })
430
566
 
431
567
  // Failing the exact rotation, anything but the same section again.
@@ -440,6 +576,12 @@ export function makeRunQueuePlanner() {
440
576
  const wantedSet = new Set(wanted)
441
577
  const movedSet = new Set(moved)
442
578
 
579
+ // Asked to stay within the quotas, a division that has had its share
580
+ // here gets nothing more, spare room or not.
581
+ if (strictQuota && owed.length === 0) {
582
+ return { blocked: true }
583
+ }
584
+
443
585
  const best = owed.length > 0
444
586
  ? owed.reduce((a, b) => {
445
587
  // The band comes first, always - which court a division holds
@@ -599,5 +741,19 @@ export function makeRunQueuePlanner() {
599
741
  return (queue?.length ?? 0) > 0
600
742
  }
601
743
 
602
- return { enqueue, prepare, pick, commit, previousSection, queued, hasQueued, runLengthFor }
744
+ /**
745
+ * The section, if any, whose run is played on shared courts.
746
+ *
747
+ * @param {number|string} competitionId
748
+ * @param {string} date
749
+ * @returns {?Object} see sharedSectionFor
750
+ */
751
+ function sharedFor(competitionId, date) {
752
+ return sharedByKey.get(queueKey(competitionId, date)) ?? null
753
+ }
754
+
755
+ return {
756
+ enqueue, prepare, pick, commit, previousSection, queued, hasQueued,
757
+ runLengthFor, sharedFor,
758
+ }
603
759
  }
package/lib/sessions.mjs CHANGED
@@ -22,6 +22,40 @@ export function activePhase(sessions, date, hour) {
22
22
 
23
23
  return active
24
24
  }
25
+ /**
26
+ * Which knockout rounds are on, as of this date and hour.
27
+ *
28
+ * A session may name them - "R32 and R16, the 11th, 09:00" - and then
29
+ * those are the only rounds that open from there, in the order the
30
+ * draws come down. A session that names none says nothing about
31
+ * rounds, and whatever the draws have left plays as it always did,
32
+ * which is every competition that has not been told otherwise.
33
+ *
34
+ * Read the same way as the phase: the last session to have started.
35
+ *
36
+ * @param {Array} sessions in date order
37
+ * @param {string} date
38
+ * @param {string} hour
39
+ * @returns {?Array<string>}
40
+ */
41
+ export function activeRounds(sessions, date, hour) {
42
+ let active = null
43
+
44
+ for (const session of sessions) {
45
+ const started = session.date === date && session.start_time <= hour
46
+
47
+ if (session.date < date || started) {
48
+ const named = session.rounds ?? (session.round ? [session.round] : null)
49
+
50
+ active = named?.length ? named : null
51
+ } else {
52
+ break
53
+ }
54
+ }
55
+
56
+ return active
57
+ }
58
+
25
59
  /**
26
60
  * The slots a competition runs on a date.
27
61
  *
@@ -114,9 +114,10 @@ function placeUnit({ unit, courts, slots, date, occupancy, darkFrom, blocks }) {
114
114
  * @param {Array<{id: number, teamIds: number[]}>} groups
115
115
  * @returns {Array<Array<{groupId: number, index: number}|null>>} slot by court
116
116
  */
117
- export function threeTeamUnitLayout(groups) {
117
+ export function threeTeamUnitLayout(groups, done = null) {
118
118
  return unitLayout(groups, {
119
119
  template: GROUP_TEMPLATES[3],
120
120
  matchesFor: group => templateMatches({ teamIds: group.teamIds, groupSize: 3 }),
121
+ done,
121
122
  })
122
123
  }
package/package.json CHANGED
@@ -40,5 +40,5 @@
40
40
  },
41
41
  "sideEffects": true,
42
42
  "type": "module",
43
- "version": "0.0.2"
43
+ "version": "0.0.4"
44
44
  }