volleyballsimtypes 0.0.491 → 0.0.492
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/dist/cjs/src/api/index.d.ts +2 -1
- package/dist/cjs/src/api/index.js +15 -0
- package/dist/cjs/src/api/tactics-player-refs.d.ts +13 -0
- package/dist/cjs/src/api/tactics-player-refs.js +43 -0
- package/dist/cjs/src/data/event-purge.d.ts +14 -0
- package/dist/cjs/src/data/event-purge.js +116 -0
- package/dist/cjs/src/data/index.d.ts +1 -0
- package/dist/cjs/src/data/index.js +1 -0
- package/dist/cjs/src/data/init-models.js +41 -0
- package/dist/cjs/src/data/models/competition.d.ts +1 -1
- package/dist/cjs/src/data/models/competition.js +1 -1
- package/dist/cjs/src/data/models/event-bot-team.d.ts +21 -0
- package/dist/cjs/src/data/models/event-bot-team.js +44 -0
- package/dist/cjs/src/data/models/event-competition.d.ts +29 -0
- package/dist/cjs/src/data/models/event-competition.js +52 -0
- package/dist/cjs/src/data/models/event-pack-player.d.ts +22 -0
- package/dist/cjs/src/data/models/event-pack-player.js +31 -0
- package/dist/cjs/src/data/models/event-pack.d.ts +37 -0
- package/dist/cjs/src/data/models/event-pack.js +55 -0
- package/dist/cjs/src/data/models/event-result.d.ts +34 -0
- package/dist/cjs/src/data/models/event-result.js +48 -0
- package/dist/cjs/src/data/models/event-squad.d.ts +30 -0
- package/dist/cjs/src/data/models/event-squad.js +46 -0
- package/dist/cjs/src/data/models/event.d.ts +51 -0
- package/dist/cjs/src/data/models/event.js +82 -0
- package/dist/cjs/src/data/models/index.d.ts +7 -0
- package/dist/cjs/src/data/models/index.js +7 -0
- package/dist/cjs/src/data/models/lineup-preset.d.ts +3 -1
- package/dist/cjs/src/data/models/lineup-preset.js +53 -0
- package/dist/cjs/src/index.d.ts +1 -0
- package/dist/cjs/src/index.js +6 -0
- package/dist/cjs/src/service/competition/event-draft.d.ts +46 -0
- package/dist/cjs/src/service/competition/event-draft.js +85 -0
- package/dist/cjs/src/service/competition/event-draft.test.d.ts +1 -0
- package/dist/cjs/src/service/competition/event-draft.test.js +91 -0
- package/dist/cjs/src/service/competition/event-rewards.d.ts +26 -0
- package/dist/cjs/src/service/competition/event-rewards.js +49 -0
- package/dist/cjs/src/service/competition/event-rewards.test.d.ts +1 -0
- package/dist/cjs/src/service/competition/event-rewards.test.js +58 -0
- package/dist/cjs/src/service/competition/event-schedule.d.ts +126 -0
- package/dist/cjs/src/service/competition/event-schedule.js +141 -0
- package/dist/cjs/src/service/competition/event-schedule.test.d.ts +1 -0
- package/dist/cjs/src/service/competition/event-schedule.test.js +138 -0
- package/dist/cjs/src/service/competition/index.d.ts +3 -0
- package/dist/cjs/src/service/competition/index.js +3 -0
- package/dist/cjs/src/service/match/index.d.ts +1 -0
- package/dist/cjs/src/service/match/index.js +1 -0
- package/dist/cjs/src/service/match/match-generator.js +25 -48
- package/dist/cjs/src/service/match/match-generator.test.d.ts +1 -0
- package/dist/cjs/src/service/match/match-generator.test.js +48 -0
- package/dist/cjs/src/service/match/round-robin.d.ts +11 -0
- package/dist/cjs/src/service/match/round-robin.js +44 -0
- package/dist/cjs/src/service/match/round-robin.test.d.ts +1 -0
- package/dist/cjs/src/service/match/round-robin.test.js +71 -0
- package/dist/cjs/src/service/notification/catalog/en.json +21 -0
- package/dist/cjs/src/service/notification/catalog/es.json +21 -0
- package/dist/cjs/src/service/notification/catalog/fr.json +21 -0
- package/dist/cjs/src/service/notification/catalog/it.json +21 -0
- package/dist/cjs/src/service/notification/catalog/pl.json +21 -0
- package/dist/cjs/src/service/notification/catalog/pt-BR.json +21 -0
- package/dist/cjs/src/service/notification/catalog/tr.json +21 -0
- package/dist/cjs/src/service/notification/notification-type.d.ts +1 -1
- package/dist/cjs/src/service/notification/notification-type.js +6 -1
- package/dist/cjs/src/service/notification/notification.test.js +3 -1
- package/dist/cjs/src/service/notification/registry.js +52 -0
- package/dist/cjs/src/service/player/player-generator.d.ts +1 -0
- package/dist/cjs/src/service/player/player-generator.js +16 -1
- package/dist/esm/src/api/index.d.ts +2 -1
- package/dist/esm/src/api/index.js +1 -0
- package/dist/esm/src/api/tactics-player-refs.d.ts +13 -0
- package/dist/esm/src/api/tactics-player-refs.js +40 -0
- package/dist/esm/src/data/event-purge.d.ts +14 -0
- package/dist/esm/src/data/event-purge.js +110 -0
- package/dist/esm/src/data/index.d.ts +1 -0
- package/dist/esm/src/data/index.js +1 -0
- package/dist/esm/src/data/init-models.js +42 -1
- package/dist/esm/src/data/models/competition.d.ts +1 -1
- package/dist/esm/src/data/models/competition.js +1 -1
- package/dist/esm/src/data/models/event-bot-team.d.ts +21 -0
- package/dist/esm/src/data/models/event-bot-team.js +40 -0
- package/dist/esm/src/data/models/event-competition.d.ts +29 -0
- package/dist/esm/src/data/models/event-competition.js +48 -0
- package/dist/esm/src/data/models/event-pack-player.d.ts +22 -0
- package/dist/esm/src/data/models/event-pack-player.js +27 -0
- package/dist/esm/src/data/models/event-pack.d.ts +37 -0
- package/dist/esm/src/data/models/event-pack.js +51 -0
- package/dist/esm/src/data/models/event-result.d.ts +34 -0
- package/dist/esm/src/data/models/event-result.js +44 -0
- package/dist/esm/src/data/models/event-squad.d.ts +30 -0
- package/dist/esm/src/data/models/event-squad.js +42 -0
- package/dist/esm/src/data/models/event.d.ts +51 -0
- package/dist/esm/src/data/models/event.js +78 -0
- package/dist/esm/src/data/models/index.d.ts +7 -0
- package/dist/esm/src/data/models/index.js +7 -0
- package/dist/esm/src/data/models/lineup-preset.d.ts +3 -1
- package/dist/esm/src/data/models/lineup-preset.js +20 -0
- package/dist/esm/src/index.d.ts +1 -0
- package/dist/esm/src/index.js +4 -0
- package/dist/esm/src/service/competition/event-draft.d.ts +46 -0
- package/dist/esm/src/service/competition/event-draft.js +78 -0
- package/dist/esm/src/service/competition/event-draft.test.d.ts +1 -0
- package/dist/esm/src/service/competition/event-draft.test.js +89 -0
- package/dist/esm/src/service/competition/event-rewards.d.ts +26 -0
- package/dist/esm/src/service/competition/event-rewards.js +44 -0
- package/dist/esm/src/service/competition/event-rewards.test.d.ts +1 -0
- package/dist/esm/src/service/competition/event-rewards.test.js +56 -0
- package/dist/esm/src/service/competition/event-schedule.d.ts +126 -0
- package/dist/esm/src/service/competition/event-schedule.js +129 -0
- package/dist/esm/src/service/competition/event-schedule.test.d.ts +1 -0
- package/dist/esm/src/service/competition/event-schedule.test.js +136 -0
- package/dist/esm/src/service/competition/index.d.ts +3 -0
- package/dist/esm/src/service/competition/index.js +3 -0
- package/dist/esm/src/service/match/index.d.ts +1 -0
- package/dist/esm/src/service/match/index.js +1 -0
- package/dist/esm/src/service/match/match-generator.js +25 -48
- package/dist/esm/src/service/match/match-generator.test.d.ts +1 -0
- package/dist/esm/src/service/match/match-generator.test.js +43 -0
- package/dist/esm/src/service/match/round-robin.d.ts +11 -0
- package/dist/esm/src/service/match/round-robin.js +41 -0
- package/dist/esm/src/service/match/round-robin.test.d.ts +1 -0
- package/dist/esm/src/service/match/round-robin.test.js +69 -0
- package/dist/esm/src/service/notification/catalog/en.json +21 -0
- package/dist/esm/src/service/notification/catalog/es.json +21 -0
- package/dist/esm/src/service/notification/catalog/fr.json +21 -0
- package/dist/esm/src/service/notification/catalog/it.json +21 -0
- package/dist/esm/src/service/notification/catalog/pl.json +21 -0
- package/dist/esm/src/service/notification/catalog/pt-BR.json +21 -0
- package/dist/esm/src/service/notification/catalog/tr.json +21 -0
- package/dist/esm/src/service/notification/notification-type.d.ts +1 -1
- package/dist/esm/src/service/notification/notification-type.js +6 -1
- package/dist/esm/src/service/notification/notification.test.js +3 -1
- package/dist/esm/src/service/notification/registry.js +52 -0
- package/dist/esm/src/service/player/player-generator.d.ts +1 -0
- package/dist/esm/src/service/player/player-generator.js +15 -0
- package/package.json +6 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it, expect } from '@jest/globals';
|
|
2
|
+
import { EVENT_REWARDS, eventPayoutFor, eventRewardBandFor } from './event-rewards';
|
|
3
|
+
describe('eventRewardBandFor()', () => {
|
|
4
|
+
it.each([
|
|
5
|
+
[1, 'TOP_1'],
|
|
6
|
+
[2, 'TOP_3'],
|
|
7
|
+
[3, 'TOP_3'],
|
|
8
|
+
[4, 'TOP_5'],
|
|
9
|
+
[5, 'TOP_5'],
|
|
10
|
+
[6, 'TOP_10'],
|
|
11
|
+
[10, 'TOP_10'],
|
|
12
|
+
[11, 'TOP_20'],
|
|
13
|
+
[20, 'TOP_20'],
|
|
14
|
+
// Inside a 24-team final but outside every banded row, so it counts as taking part.
|
|
15
|
+
[21, 'PARTICIPATION'],
|
|
16
|
+
[24, 'PARTICIPATION']
|
|
17
|
+
])('places a finish of %i in %s', (position, band) => {
|
|
18
|
+
expect(eventRewardBandFor(position)).toBe(band);
|
|
19
|
+
});
|
|
20
|
+
it('treats a team that never reached the final as participation', () => {
|
|
21
|
+
expect(eventRewardBandFor(null)).toBe('PARTICIPATION');
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
describe('eventPayoutFor()', () => {
|
|
25
|
+
it.each([
|
|
26
|
+
// position, packs, coins. Cumulative: every row from Participation down to the finish.
|
|
27
|
+
[null, 1, 1000], // group stage: 1 pack + 1000
|
|
28
|
+
[24, 1, 1000], // in the final but outside the top 20: still just participation
|
|
29
|
+
[20, 1, 2000], // + top 20's 1000
|
|
30
|
+
[11, 1, 2000],
|
|
31
|
+
[10, 2, 2000], // + top 10's pack
|
|
32
|
+
[6, 2, 2000],
|
|
33
|
+
[5, 2, 3000], // + top 5's 1000
|
|
34
|
+
[4, 2, 3000],
|
|
35
|
+
[3, 2, 3500], // + top 3's 500
|
|
36
|
+
[2, 2, 3500],
|
|
37
|
+
[1, 3, 3500] // + the winner's pack
|
|
38
|
+
])('pays a finish of %s with %i packs and %i coins', (position, packs, coins) => {
|
|
39
|
+
expect(eventPayoutFor(position)).toEqual({ packs, coins });
|
|
40
|
+
});
|
|
41
|
+
it('never pays less for a better finish', () => {
|
|
42
|
+
const finishes = [null, 24, 20, 11, 10, 6, 5, 4, 3, 2, 1];
|
|
43
|
+
for (let i = 1; i < finishes.length; i++) {
|
|
44
|
+
const worse = eventPayoutFor(finishes[i - 1]);
|
|
45
|
+
const better = eventPayoutFor(finishes[i]);
|
|
46
|
+
expect(better.packs).toBeGreaterThanOrEqual(worse.packs);
|
|
47
|
+
expect(better.coins).toBeGreaterThanOrEqual(worse.coins);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
it('adds up to the owner-defined table for a winner', () => {
|
|
51
|
+
// 1x pack + 1000 (participation), +1000 (top 20), +1 pack (top 10), +1000 (top 5), +500 (top 3), +1 pack (top 1)
|
|
52
|
+
const total = EVENT_REWARDS.reduce((acc, row) => ({ packs: acc.packs + row.packs, coins: acc.coins + row.coins }), { packs: 0, coins: 0 });
|
|
53
|
+
expect(eventPayoutFor(1)).toEqual(total);
|
|
54
|
+
expect(total).toEqual({ packs: 3, coins: 3500 });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Rarity } from '../player';
|
|
2
|
+
export type EventKey = 'WAR_OF_THE_COMMONS' | 'RARE_GEMS' | 'STUFF_OF_LEGENDS';
|
|
3
|
+
/**
|
|
4
|
+
* How an event is played out. One member today; a new format is a new member here AND the code path in the
|
|
5
|
+
* simulator that builds its schedule, which is why formats live in code rather than in a table.
|
|
6
|
+
* - `GROUPS_FINAL` equal groups playing a round robin, then one final group of the qualifiers on the last day.
|
|
7
|
+
*/
|
|
8
|
+
export type EventFormat = 'GROUPS_FINAL';
|
|
9
|
+
/**
|
|
10
|
+
* Where an event is in its life.
|
|
11
|
+
* - `REGISTRATION` the lineup can be saved; saving is what registers the team.
|
|
12
|
+
* - `IN_PROGRESS` matches are scheduled and playing (group stage, then the final).
|
|
13
|
+
* - `REVIEW` finished; results readable, nothing playing.
|
|
14
|
+
* - `ARCHIVED` purged; only the Event row itself survives.
|
|
15
|
+
*/
|
|
16
|
+
export type EventStatus = 'REGISTRATION' | 'IN_PROGRESS' | 'REVIEW' | 'ARCHIVED';
|
|
17
|
+
/**
|
|
18
|
+
* A preset: everything about HOW an event works. An admin cannot change any of it, they pick one of these and
|
|
19
|
+
* say when it starts, so an event can never be configured into a shape the simulator cannot run.
|
|
20
|
+
*/
|
|
21
|
+
export interface EventDefinition {
|
|
22
|
+
readonly key: EventKey;
|
|
23
|
+
readonly format: EventFormat;
|
|
24
|
+
/** Every player referenced anywhere in the event lineup must be EXACTLY this rarity. */
|
|
25
|
+
readonly rarity: Rarity;
|
|
26
|
+
/** Total days from the start, the last of which is the final. */
|
|
27
|
+
readonly durationDays: number;
|
|
28
|
+
}
|
|
29
|
+
export declare const EVENT_CATALOGUE: readonly EventDefinition[];
|
|
30
|
+
/** An event must be long enough to hold a group stage AND a final day. */
|
|
31
|
+
export declare const MIN_DURATION_DAYS = 2;
|
|
32
|
+
/** Registration has to give players a real chance to build a squad, and cannot be scheduled absurdly far out. */
|
|
33
|
+
export declare const MIN_REGISTRATION_DAYS = 1;
|
|
34
|
+
export declare const MAX_REGISTRATION_DAYS = 30;
|
|
35
|
+
/** Results stay readable for this long after an event ends, then everything but the Event row is deleted. */
|
|
36
|
+
export declare const REVIEW_DAYS = 3;
|
|
37
|
+
/**
|
|
38
|
+
* The final group is 24 teams: at one match per hour that is 23 rounds, which is the largest round robin that
|
|
39
|
+
* still fits inside the single final day.
|
|
40
|
+
*/
|
|
41
|
+
export declare const MAX_FINAL_SIZE = 24;
|
|
42
|
+
/** A group is padded with bots to at least this many teams, so a small turnout still plays a real group stage. */
|
|
43
|
+
export declare const MIN_GROUP_SIZE = 16;
|
|
44
|
+
/** Rounds are never closer together than an hour, which is what bounds how large a group can be. */
|
|
45
|
+
export declare const MIN_ROUND_SPACING_MINUTES = 60;
|
|
46
|
+
/** One match per hour in the final: 24 teams = 23 rounds, 00:00 to 23:00. */
|
|
47
|
+
export declare const FINAL_ROUND_SPACING_MINUTES = 60;
|
|
48
|
+
/**
|
|
49
|
+
* How a turnout is split into groups, and how many of each group go through.
|
|
50
|
+
*
|
|
51
|
+
* Owner-defined (2026-07-23). Read as "fewer than `maxParticipants`". Every row multiplies out to exactly
|
|
52
|
+
* MAX_FINAL_SIZE, so the final is always 24 teams however many entered, and every group sends the same number
|
|
53
|
+
* through. Group counts that cannot divide 24 into whole qualifiers (5, 10) are deliberately absent.
|
|
54
|
+
*/
|
|
55
|
+
export interface EventGroupTier {
|
|
56
|
+
/** Exclusive upper bound on the entrant count for this tier. */
|
|
57
|
+
readonly maxParticipants: number;
|
|
58
|
+
readonly groupCount: number;
|
|
59
|
+
readonly qualifiersPerGroup: number;
|
|
60
|
+
}
|
|
61
|
+
export declare const EVENT_GROUP_TIERS: readonly EventGroupTier[];
|
|
62
|
+
/** The tier a turnout falls into. The last row has no upper bound, so this always resolves. */
|
|
63
|
+
export declare function eventGroupTierFor(participantCount: number): EventGroupTier;
|
|
64
|
+
/** The preset a stored event was created from. Its rules are read from this, never re-derived. */
|
|
65
|
+
export declare function eventDefinitionForKey(key: EventKey): EventDefinition;
|
|
66
|
+
export interface EventWindows {
|
|
67
|
+
readonly key: EventKey;
|
|
68
|
+
readonly format: EventFormat;
|
|
69
|
+
readonly rarity: Rarity;
|
|
70
|
+
/** Registration opens this many days before the start. */
|
|
71
|
+
readonly registrationOpensAt: Date;
|
|
72
|
+
/** Registration closes the instant the event starts. */
|
|
73
|
+
readonly registrationClosesAt: Date;
|
|
74
|
+
readonly startsAt: Date;
|
|
75
|
+
/** 00:00 UTC on the last day: the final is a single day, one round per hour. */
|
|
76
|
+
readonly finalStartsAt: Date;
|
|
77
|
+
/** The exclusive end of the last day. */
|
|
78
|
+
readonly endsAt: Date;
|
|
79
|
+
/** Review ends and everything but the Event row is deleted. */
|
|
80
|
+
readonly purgeAt: Date;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Every date an event will ever have, computed once from its preset, its start day and how much lead time
|
|
84
|
+
* registration gets. The start is floored to 00:00 UTC so an event always runs whole days.
|
|
85
|
+
*
|
|
86
|
+
* ```
|
|
87
|
+
* start - registrationDays registration opens
|
|
88
|
+
* start registration closes, group stage begins
|
|
89
|
+
* start + duration - 1 the final (one day)
|
|
90
|
+
* start + duration the event ends, review begins
|
|
91
|
+
* start + duration + 3 purge
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
export declare function eventWindowsFor(definition: EventDefinition, startsAt: Date, registrationDays: number): EventWindows;
|
|
95
|
+
/** How long the group stage has, in hours: everything before the final day. */
|
|
96
|
+
export declare function groupStageHours(definition: EventDefinition): number;
|
|
97
|
+
export declare function eventStatusAt(windows: EventWindows, now: Date): EventStatus;
|
|
98
|
+
export interface EventGroupPlan {
|
|
99
|
+
readonly groupCount: number;
|
|
100
|
+
/** Equal across every group, always even, bots making up the difference. */
|
|
101
|
+
readonly groupSize: number;
|
|
102
|
+
/** How many bot teams have to be generated to fill the groups. */
|
|
103
|
+
readonly botCount: number;
|
|
104
|
+
readonly qualifiersPerGroup: number;
|
|
105
|
+
readonly finalSize: number;
|
|
106
|
+
/** Minutes between group-stage rounds, so the whole round robin lands inside the group-stage window. */
|
|
107
|
+
readonly groupRoundSpacingMinutes: number;
|
|
108
|
+
/** True when the group stage cannot fit even at the minimum spacing; the caller must log it loudly. */
|
|
109
|
+
readonly overflows: boolean;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Rounds start on the hour or the half hour and nothing else, so a kickoff never reads as 19:12.
|
|
113
|
+
*
|
|
114
|
+
* Snapped DOWN, so a stage that already fits its window still fits: rounding up could push the last round past
|
|
115
|
+
* the final. Events start at 00:00 UTC, so every round lands on the grid.
|
|
116
|
+
*/
|
|
117
|
+
export declare const ROUND_SPACING_GRID_MINUTES = 30;
|
|
118
|
+
/**
|
|
119
|
+
* How a turnout of `participantCount` real teams is split, and how the round robin is paced to fit
|
|
120
|
+
* `stageHours` (the event's days before its final day, from groupStageHours).
|
|
121
|
+
*
|
|
122
|
+
* The group count and the number that go through come from EVENT_GROUP_TIERS. Groups are all equal in size,
|
|
123
|
+
* always even (a round robin needs an even field for everyone to play every round) and bot-padded to at least
|
|
124
|
+
* MIN_GROUP_SIZE, so a small turnout still plays a full group stage.
|
|
125
|
+
*/
|
|
126
|
+
export declare function planEventGroups(participantCount: number, stageHours: number): EventGroupPlan;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import dayjs from 'dayjs';
|
|
2
|
+
import utc from 'dayjs/plugin/utc';
|
|
3
|
+
import { RarityEnum } from '../player';
|
|
4
|
+
dayjs.extend(utc);
|
|
5
|
+
// The presets an admin can schedule. Adding one here (plus its name in the 7 locale files) is all a new event
|
|
6
|
+
// needs, as long as its format is one the simulator already builds.
|
|
7
|
+
export const EVENT_CATALOGUE = [
|
|
8
|
+
{ key: 'WAR_OF_THE_COMMONS', format: 'GROUPS_FINAL', rarity: RarityEnum.COMMON, durationDays: 7 },
|
|
9
|
+
{ key: 'RARE_GEMS', format: 'GROUPS_FINAL', rarity: RarityEnum.RARE, durationDays: 7 },
|
|
10
|
+
{ key: 'STUFF_OF_LEGENDS', format: 'GROUPS_FINAL', rarity: RarityEnum.LEGENDARY, durationDays: 7 }
|
|
11
|
+
];
|
|
12
|
+
/** An event must be long enough to hold a group stage AND a final day. */
|
|
13
|
+
export const MIN_DURATION_DAYS = 2;
|
|
14
|
+
/** Registration has to give players a real chance to build a squad, and cannot be scheduled absurdly far out. */
|
|
15
|
+
export const MIN_REGISTRATION_DAYS = 1;
|
|
16
|
+
export const MAX_REGISTRATION_DAYS = 30;
|
|
17
|
+
/** Results stay readable for this long after an event ends, then everything but the Event row is deleted. */
|
|
18
|
+
export const REVIEW_DAYS = 3;
|
|
19
|
+
/**
|
|
20
|
+
* The final group is 24 teams: at one match per hour that is 23 rounds, which is the largest round robin that
|
|
21
|
+
* still fits inside the single final day.
|
|
22
|
+
*/
|
|
23
|
+
export const MAX_FINAL_SIZE = 24;
|
|
24
|
+
/** A group is padded with bots to at least this many teams, so a small turnout still plays a real group stage. */
|
|
25
|
+
export const MIN_GROUP_SIZE = 16;
|
|
26
|
+
/** Rounds are never closer together than an hour, which is what bounds how large a group can be. */
|
|
27
|
+
export const MIN_ROUND_SPACING_MINUTES = 60;
|
|
28
|
+
/** One match per hour in the final: 24 teams = 23 rounds, 00:00 to 23:00. */
|
|
29
|
+
export const FINAL_ROUND_SPACING_MINUTES = 60;
|
|
30
|
+
export const EVENT_GROUP_TIERS = [
|
|
31
|
+
{ maxParticipants: 64, groupCount: 3, qualifiersPerGroup: 8 },
|
|
32
|
+
{ maxParticipants: 96, groupCount: 4, qualifiersPerGroup: 6 },
|
|
33
|
+
{ maxParticipants: 128, groupCount: 6, qualifiersPerGroup: 4 },
|
|
34
|
+
{ maxParticipants: 192, groupCount: 8, qualifiersPerGroup: 3 },
|
|
35
|
+
{ maxParticipants: Number.POSITIVE_INFINITY, groupCount: 12, qualifiersPerGroup: 2 }
|
|
36
|
+
];
|
|
37
|
+
/** The tier a turnout falls into. The last row has no upper bound, so this always resolves. */
|
|
38
|
+
export function eventGroupTierFor(participantCount) {
|
|
39
|
+
return EVENT_GROUP_TIERS.find(tier => participantCount < tier.maxParticipants) ?? EVENT_GROUP_TIERS[EVENT_GROUP_TIERS.length - 1];
|
|
40
|
+
}
|
|
41
|
+
/** The preset a stored event was created from. Its rules are read from this, never re-derived. */
|
|
42
|
+
export function eventDefinitionForKey(key) {
|
|
43
|
+
const definition = EVENT_CATALOGUE.find(e => e.key === key);
|
|
44
|
+
if (definition == null)
|
|
45
|
+
throw new Error(`UNKNOWN_EVENT_KEY: ${key}`);
|
|
46
|
+
return definition;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Every date an event will ever have, computed once from its preset, its start day and how much lead time
|
|
50
|
+
* registration gets. The start is floored to 00:00 UTC so an event always runs whole days.
|
|
51
|
+
*
|
|
52
|
+
* ```
|
|
53
|
+
* start - registrationDays registration opens
|
|
54
|
+
* start registration closes, group stage begins
|
|
55
|
+
* start + duration - 1 the final (one day)
|
|
56
|
+
* start + duration the event ends, review begins
|
|
57
|
+
* start + duration + 3 purge
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export function eventWindowsFor(definition, startsAt, registrationDays) {
|
|
61
|
+
const start = dayjs.utc(startsAt).startOf('day');
|
|
62
|
+
return {
|
|
63
|
+
key: definition.key,
|
|
64
|
+
format: definition.format,
|
|
65
|
+
rarity: definition.rarity,
|
|
66
|
+
registrationOpensAt: start.subtract(registrationDays, 'day').toDate(),
|
|
67
|
+
registrationClosesAt: start.toDate(),
|
|
68
|
+
startsAt: start.toDate(),
|
|
69
|
+
finalStartsAt: start.add(definition.durationDays - 1, 'day').toDate(),
|
|
70
|
+
endsAt: start.add(definition.durationDays, 'day').toDate(),
|
|
71
|
+
purgeAt: start.add(definition.durationDays + REVIEW_DAYS, 'day').toDate()
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** How long the group stage has, in hours: everything before the final day. */
|
|
75
|
+
export function groupStageHours(definition) {
|
|
76
|
+
return (definition.durationDays - 1) * 24;
|
|
77
|
+
}
|
|
78
|
+
export function eventStatusAt(windows, now) {
|
|
79
|
+
const t = now.getTime();
|
|
80
|
+
if (t < windows.startsAt.getTime())
|
|
81
|
+
return 'REGISTRATION';
|
|
82
|
+
if (t < windows.endsAt.getTime())
|
|
83
|
+
return 'IN_PROGRESS';
|
|
84
|
+
if (t < windows.purgeAt.getTime())
|
|
85
|
+
return 'REVIEW';
|
|
86
|
+
return 'ARCHIVED';
|
|
87
|
+
}
|
|
88
|
+
function evenUp(n) {
|
|
89
|
+
return n % 2 === 0 ? n : n + 1;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Rounds start on the hour or the half hour and nothing else, so a kickoff never reads as 19:12.
|
|
93
|
+
*
|
|
94
|
+
* Snapped DOWN, so a stage that already fits its window still fits: rounding up could push the last round past
|
|
95
|
+
* the final. Events start at 00:00 UTC, so every round lands on the grid.
|
|
96
|
+
*/
|
|
97
|
+
export const ROUND_SPACING_GRID_MINUTES = 30;
|
|
98
|
+
function snapToGrid(minutes) {
|
|
99
|
+
return Math.floor(minutes / ROUND_SPACING_GRID_MINUTES) * ROUND_SPACING_GRID_MINUTES;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* How a turnout of `participantCount` real teams is split, and how the round robin is paced to fit
|
|
103
|
+
* `stageHours` (the event's days before its final day, from groupStageHours).
|
|
104
|
+
*
|
|
105
|
+
* The group count and the number that go through come from EVENT_GROUP_TIERS. Groups are all equal in size,
|
|
106
|
+
* always even (a round robin needs an even field for everyone to play every round) and bot-padded to at least
|
|
107
|
+
* MIN_GROUP_SIZE, so a small turnout still plays a full group stage.
|
|
108
|
+
*/
|
|
109
|
+
export function planEventGroups(participantCount, stageHours) {
|
|
110
|
+
const tier = eventGroupTierFor(participantCount);
|
|
111
|
+
const groupCount = tier.groupCount;
|
|
112
|
+
const groupSize = Math.max(MIN_GROUP_SIZE, evenUp(Math.ceil(participantCount / groupCount)));
|
|
113
|
+
// Capped by the group size purely as a guard; with a 16-team minimum and at most 8 going through it never binds.
|
|
114
|
+
const qualifiersPerGroup = Math.min(tier.qualifiersPerGroup, groupSize);
|
|
115
|
+
// One round per pairing: an even group plays groupSize-1 rounds, spread across the whole group-stage window.
|
|
116
|
+
const rounds = groupSize - 1;
|
|
117
|
+
const spacing = Math.floor((stageHours * 60) / rounds);
|
|
118
|
+
return {
|
|
119
|
+
groupCount,
|
|
120
|
+
groupSize,
|
|
121
|
+
botCount: groupCount * groupSize - participantCount,
|
|
122
|
+
qualifiersPerGroup,
|
|
123
|
+
finalSize: qualifiersPerGroup * groupCount,
|
|
124
|
+
groupRoundSpacingMinutes: Math.max(MIN_ROUND_SPACING_MINUTES, snapToGrid(spacing)),
|
|
125
|
+
// Judged on the UNSNAPPED spacing: snapping only ever shortens the gap, so it cannot rescue a stage that
|
|
126
|
+
// already does not fit, and judging the snapped value would hide a genuine overflow.
|
|
127
|
+
overflows: spacing < MIN_ROUND_SPACING_MINUTES
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, it, expect } from '@jest/globals';
|
|
2
|
+
import { EVENT_CATALOGUE, MAX_FINAL_SIZE, MIN_GROUP_SIZE, MIN_ROUND_SPACING_MINUTES, REVIEW_DAYS, eventDefinitionForKey, eventStatusAt, eventWindowsFor, groupStageHours, planEventGroups } from './event-schedule';
|
|
3
|
+
import { RarityEnum } from '../player';
|
|
4
|
+
const COMMONS = eventDefinitionForKey('WAR_OF_THE_COMMONS');
|
|
5
|
+
describe('EVENT_CATALOGUE', () => {
|
|
6
|
+
it('restricts the three launch presets to a single rarity each, over one week', () => {
|
|
7
|
+
expect(EVENT_CATALOGUE.map(e => e.rarity)).toEqual([RarityEnum.COMMON, RarityEnum.RARE, RarityEnum.LEGENDARY]);
|
|
8
|
+
expect(EVENT_CATALOGUE.every(e => e.durationDays === 7)).toBe(true);
|
|
9
|
+
expect(EVENT_CATALOGUE.every(e => e.format === 'GROUPS_FINAL')).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
it('leaves room for a final day in every preset', () => {
|
|
12
|
+
expect(EVENT_CATALOGUE.every(e => e.durationDays >= 2)).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
describe('eventDefinitionForKey()', () => {
|
|
16
|
+
it('reads the rules off the key an event stores', () => {
|
|
17
|
+
expect(eventDefinitionForKey('STUFF_OF_LEGENDS').rarity).toBe(RarityEnum.LEGENDARY);
|
|
18
|
+
expect(eventDefinitionForKey('RARE_GEMS').rarity).toBe(RarityEnum.RARE);
|
|
19
|
+
expect(eventDefinitionForKey('WAR_OF_THE_COMMONS').rarity).toBe(RarityEnum.COMMON);
|
|
20
|
+
});
|
|
21
|
+
it('throws on a key that is not in the catalogue', () => {
|
|
22
|
+
expect(() => eventDefinitionForKey('NOT_AN_EVENT')).toThrow('UNKNOWN_EVENT_KEY');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe('eventWindowsFor()', () => {
|
|
26
|
+
it('derives every date from the preset, the start day and the registration lead time', () => {
|
|
27
|
+
const w = eventWindowsFor(COMMONS, new Date('2026-08-03T00:00:00.000Z'), 4);
|
|
28
|
+
expect(w.registrationOpensAt.toISOString()).toBe('2026-07-30T00:00:00.000Z'); // start - 4 days
|
|
29
|
+
expect(w.registrationClosesAt.toISOString()).toBe('2026-08-03T00:00:00.000Z'); // = startsAt
|
|
30
|
+
expect(w.startsAt.toISOString()).toBe('2026-08-03T00:00:00.000Z');
|
|
31
|
+
expect(w.finalStartsAt.toISOString()).toBe('2026-08-09T00:00:00.000Z'); // last day of 7
|
|
32
|
+
expect(w.endsAt.toISOString()).toBe('2026-08-10T00:00:00.000Z');
|
|
33
|
+
expect(w.purgeAt.toISOString()).toBe('2026-08-13T00:00:00.000Z'); // + 3 review days
|
|
34
|
+
});
|
|
35
|
+
it('floors the start to 00:00 UTC so an event always runs whole days', () => {
|
|
36
|
+
const w = eventWindowsFor(COMMONS, new Date('2026-08-03T17:42:11.000Z'), 4);
|
|
37
|
+
expect(w.startsAt.toISOString()).toBe('2026-08-03T00:00:00.000Z');
|
|
38
|
+
});
|
|
39
|
+
it('moves registration with the lead time and nothing else', () => {
|
|
40
|
+
const a = eventWindowsFor(COMMONS, new Date('2026-08-03T00:00:00.000Z'), 1);
|
|
41
|
+
const b = eventWindowsFor(COMMONS, new Date('2026-08-03T00:00:00.000Z'), 14);
|
|
42
|
+
expect(a.registrationOpensAt.toISOString()).toBe('2026-08-02T00:00:00.000Z');
|
|
43
|
+
expect(b.registrationOpensAt.toISOString()).toBe('2026-07-20T00:00:00.000Z');
|
|
44
|
+
expect(a.startsAt.getTime()).toBe(b.startsAt.getTime());
|
|
45
|
+
expect(a.endsAt.getTime()).toBe(b.endsAt.getTime());
|
|
46
|
+
expect(a.purgeAt.getTime()).toBe(b.purgeAt.getTime());
|
|
47
|
+
});
|
|
48
|
+
it('gives the final exactly one day, whatever the duration', () => {
|
|
49
|
+
for (const durationDays of [2, 3, 7, 10]) {
|
|
50
|
+
const w = eventWindowsFor({ ...COMMONS, durationDays }, new Date('2026-08-03T00:00:00.000Z'), 4);
|
|
51
|
+
expect(w.endsAt.getTime() - w.finalStartsAt.getTime()).toBe(24 * 3600 * 1000);
|
|
52
|
+
expect(w.endsAt.getTime() - w.startsAt.getTime()).toBe(durationDays * 24 * 3600 * 1000);
|
|
53
|
+
expect(w.purgeAt.getTime() - w.endsAt.getTime()).toBe(REVIEW_DAYS * 24 * 3600 * 1000);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe('groupStageHours()', () => {
|
|
58
|
+
it('is every day before the final day', () => {
|
|
59
|
+
expect(groupStageHours({ ...COMMONS, durationDays: 7 })).toBe(144);
|
|
60
|
+
expect(groupStageHours({ ...COMMONS, durationDays: 2 })).toBe(24);
|
|
61
|
+
expect(groupStageHours({ ...COMMONS, durationDays: 10 })).toBe(216);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
describe('eventStatusAt()', () => {
|
|
65
|
+
const w = eventWindowsFor(COMMONS, new Date('2026-08-03T00:00:00.000Z'), 4);
|
|
66
|
+
it.each([
|
|
67
|
+
['2026-07-30T00:00:00.000Z', 'REGISTRATION'],
|
|
68
|
+
['2026-08-02T23:59:59.000Z', 'REGISTRATION'],
|
|
69
|
+
['2026-08-03T00:00:00.000Z', 'IN_PROGRESS'],
|
|
70
|
+
['2026-08-09T12:00:00.000Z', 'IN_PROGRESS'],
|
|
71
|
+
['2026-08-09T23:59:59.000Z', 'IN_PROGRESS'],
|
|
72
|
+
['2026-08-10T00:00:00.000Z', 'REVIEW'],
|
|
73
|
+
['2026-08-12T23:59:59.000Z', 'REVIEW'],
|
|
74
|
+
['2026-08-13T00:00:00.000Z', 'ARCHIVED']
|
|
75
|
+
])('reads %s as %s', (iso, status) => {
|
|
76
|
+
expect(eventStatusAt(w, new Date(iso))).toBe(status);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
describe('planEventGroups()', () => {
|
|
80
|
+
const WEEK = groupStageHours(COMMONS); // 144h, the six days before the final
|
|
81
|
+
it.each([
|
|
82
|
+
// turnout, groups, size, qualifiers/group, final. Tiers: <64 = 3 groups top 8, <96 = 4 top 6,
|
|
83
|
+
// <128 = 6 top 4, <192 = 8 top 3, otherwise 12 top 2. Every tier multiplies out to a 24-team final.
|
|
84
|
+
[1, 3, MIN_GROUP_SIZE, 8, 24],
|
|
85
|
+
[12, 3, MIN_GROUP_SIZE, 8, 24],
|
|
86
|
+
[48, 3, MIN_GROUP_SIZE, 8, 24],
|
|
87
|
+
[63, 3, 22, 8, 24],
|
|
88
|
+
[64, 4, MIN_GROUP_SIZE, 6, 24],
|
|
89
|
+
[95, 4, 24, 6, 24],
|
|
90
|
+
[96, 6, MIN_GROUP_SIZE, 4, 24],
|
|
91
|
+
[127, 6, 22, 4, 24],
|
|
92
|
+
[128, 8, MIN_GROUP_SIZE, 3, 24],
|
|
93
|
+
[191, 8, 24, 3, 24],
|
|
94
|
+
[192, 12, MIN_GROUP_SIZE, 2, 24],
|
|
95
|
+
[300, 12, 26, 2, 24]
|
|
96
|
+
])('splits a turnout of %i into %i groups of %i', (n, groupCount, groupSize, qualifiersPerGroup, finalSize) => {
|
|
97
|
+
const plan = planEventGroups(n, WEEK);
|
|
98
|
+
expect(plan.groupCount).toBe(groupCount);
|
|
99
|
+
expect(plan.groupSize).toBe(groupSize);
|
|
100
|
+
expect(plan.qualifiersPerGroup).toBe(qualifiersPerGroup);
|
|
101
|
+
expect(plan.finalSize).toBe(finalSize);
|
|
102
|
+
expect(plan.botCount).toBe(groupCount * groupSize - n);
|
|
103
|
+
});
|
|
104
|
+
it.each([1, 2, 7, 12, 33, 40, 41, 60, 61, 99, 100, 137, 250, 500])('keeps every group even, bot-padded and inside the final cap for a turnout of %i', (n) => {
|
|
105
|
+
const plan = planEventGroups(n, WEEK);
|
|
106
|
+
expect(plan.groupSize % 2).toBe(0); // even, so nobody sits a group-stage round out
|
|
107
|
+
expect(plan.groupSize).toBeGreaterThanOrEqual(MIN_GROUP_SIZE);
|
|
108
|
+
expect([3, 4, 6, 8, 12]).toContain(plan.groupCount);
|
|
109
|
+
expect(plan.botCount).toBeGreaterThanOrEqual(0);
|
|
110
|
+
expect(plan.groupCount * plan.groupSize).toBe(n + plan.botCount);
|
|
111
|
+
// Every tier is built to fill the final exactly, however many entered.
|
|
112
|
+
expect(plan.finalSize).toBe(MAX_FINAL_SIZE);
|
|
113
|
+
expect(plan.finalSize % 2).toBe(0); // an odd final would leave a team without an opponent every round
|
|
114
|
+
expect(plan.qualifiersPerGroup).toBeLessThanOrEqual(plan.groupSize);
|
|
115
|
+
expect(plan.groupRoundSpacingMinutes).toBeGreaterThanOrEqual(MIN_ROUND_SPACING_MINUTES);
|
|
116
|
+
// Rounds start on the hour or the half hour, never at 19:12.
|
|
117
|
+
expect(plan.groupRoundSpacingMinutes % 30).toBe(0);
|
|
118
|
+
});
|
|
119
|
+
it('fits the whole round robin inside the group-stage window until it genuinely cannot', () => {
|
|
120
|
+
for (const n of [1, 40, 100, 250, 700]) {
|
|
121
|
+
const plan = planEventGroups(n, WEEK);
|
|
122
|
+
expect(plan.overflows).toBe(false);
|
|
123
|
+
expect((plan.groupSize - 1) * plan.groupRoundSpacingMinutes).toBeLessThanOrEqual(WEEK * 60);
|
|
124
|
+
}
|
|
125
|
+
// 12 groups of 168 needs 167 rounds; at the one-hour minimum that is far more than six days, so the plan
|
|
126
|
+
// reports the overflow instead of silently generating a schedule that runs past the deadline.
|
|
127
|
+
expect(planEventGroups(2000, WEEK).overflows).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
it('paces rounds to the window it is given, so a shorter event packs them tighter', () => {
|
|
130
|
+
const long = planEventGroups(40, WEEK);
|
|
131
|
+
const short = planEventGroups(40, groupStageHours({ ...COMMONS, durationDays: 3 })); // 48h
|
|
132
|
+
expect(short.groupSize).toBe(long.groupSize);
|
|
133
|
+
expect(short.groupRoundSpacingMinutes).toBeLessThan(long.groupRoundSpacingMinutes);
|
|
134
|
+
expect((short.groupSize - 1) * short.groupRoundSpacingMinutes).toBeLessThanOrEqual(48 * 60);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -2,9 +2,13 @@ import dayjs from 'dayjs';
|
|
|
2
2
|
import utc from 'dayjs/plugin/utc';
|
|
3
3
|
import { v4 as uuidv4 } from 'uuid';
|
|
4
4
|
import { Match } from './match';
|
|
5
|
+
import { roundRobinRounds } from './round-robin';
|
|
5
6
|
import { shuffle } from '../utils';
|
|
6
7
|
import { StatusEnum } from '../../data';
|
|
7
8
|
dayjs.extend(utc);
|
|
9
|
+
// Consecutive matches in a league season start half an hour apart, so a 16-team double round robin (240 matches)
|
|
10
|
+
// spans five days from the iteration's Monday.
|
|
11
|
+
const MATCH_SPACING_MINUTES = 30;
|
|
8
12
|
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
|
|
9
13
|
export default class MatchGenerator {
|
|
10
14
|
static generateMatches(teams, baseDate) {
|
|
@@ -16,57 +20,30 @@ export default class MatchGenerator {
|
|
|
16
20
|
if (t.size !== teams.length) {
|
|
17
21
|
throw new Error('DUPLICATE_TEAMS');
|
|
18
22
|
}
|
|
19
|
-
|
|
20
|
-
const l = shuffled.length - 1;
|
|
21
|
-
const half = Math.ceil(shuffled.length / 2);
|
|
22
|
-
const matches = [];
|
|
23
|
-
// We fix the first team
|
|
24
|
-
let firstTeam = shuffled.shift();
|
|
25
|
-
if (firstTeam == null) {
|
|
23
|
+
if (teams.length < 1) {
|
|
26
24
|
throw new Error('EMPTY_TEAM_ARRAY');
|
|
27
25
|
}
|
|
28
|
-
|
|
29
|
-
let awayRow;
|
|
30
|
-
for (let i = 0; i < l; i++) {
|
|
31
|
-
// split array in 2 halves
|
|
32
|
-
const row1 = shuffled.slice(0, half);
|
|
33
|
-
const row2 = shuffled.slice(half);
|
|
34
|
-
row2.push(firstTeam);
|
|
35
|
-
if (i % 2 === 0) {
|
|
36
|
-
homeRow = row1;
|
|
37
|
-
awayRow = row2;
|
|
38
|
-
}
|
|
39
|
-
else {
|
|
40
|
-
homeRow = row2;
|
|
41
|
-
awayRow = row1;
|
|
42
|
-
}
|
|
43
|
-
for (let j = 0; j < half; j++) {
|
|
44
|
-
matches.push(Match.create({
|
|
45
|
-
id: uuidv4(),
|
|
46
|
-
homeTeam: homeRow[j],
|
|
47
|
-
awayTeam: awayRow[j],
|
|
48
|
-
scheduledDate: date.toDate(),
|
|
49
|
-
status: StatusEnum.PENDING
|
|
50
|
-
}));
|
|
51
|
-
date = date.add(30, 'minutes');
|
|
52
|
-
}
|
|
53
|
-
firstTeam = row2.pop();
|
|
54
|
-
row1.unshift(row2.shift());
|
|
55
|
-
row2.push(row1.pop());
|
|
56
|
-
shuffled = [...row1, ...row2];
|
|
57
|
-
}
|
|
58
|
-
// Generate Inverted matches for home-away pairings
|
|
59
|
-
matches.push(...matches.map(match => {
|
|
26
|
+
const nextDate = () => {
|
|
60
27
|
const scheduledDate = date.toDate();
|
|
61
|
-
date = date.add(
|
|
62
|
-
return
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
28
|
+
date = date.add(MATCH_SPACING_MINUTES, 'minutes');
|
|
29
|
+
return scheduledDate;
|
|
30
|
+
};
|
|
31
|
+
// First leg: one pass of the round robin, matches laid out back to back in round order.
|
|
32
|
+
const firstLeg = roundRobinRounds(shuffle([...teams])).flat().map(([homeTeam, awayTeam]) => Match.create({
|
|
33
|
+
id: uuidv4(),
|
|
34
|
+
homeTeam,
|
|
35
|
+
awayTeam,
|
|
36
|
+
scheduledDate: nextDate(),
|
|
37
|
+
status: StatusEnum.PENDING
|
|
38
|
+
}));
|
|
39
|
+
// Second leg: the same fixtures with the sides swapped, so every pairing is played home and away.
|
|
40
|
+
const secondLeg = firstLeg.map(match => Match.create({
|
|
41
|
+
id: uuidv4(),
|
|
42
|
+
homeTeam: match.awayTeam,
|
|
43
|
+
awayTeam: match.homeTeam,
|
|
44
|
+
scheduledDate: nextDate(),
|
|
45
|
+
status: StatusEnum.PENDING
|
|
69
46
|
}));
|
|
70
|
-
return
|
|
47
|
+
return [...firstLeg, ...secondLeg];
|
|
71
48
|
}
|
|
72
49
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect } from '@jest/globals';
|
|
2
|
+
import MatchGenerator from './match-generator';
|
|
3
|
+
import { makeTeam } from '../test-helpers';
|
|
4
|
+
// The league season schedule. These assertions pin the behaviour the rollover depends on, so the shared
|
|
5
|
+
// roundRobinRounds helper (used by events too) cannot silently change how a season is laid out.
|
|
6
|
+
const MONDAY = new Date('2026-07-20T00:00:00.000Z');
|
|
7
|
+
function orderedKey(match) {
|
|
8
|
+
return `${match.homeTeam.id}>${match.awayTeam.id}`;
|
|
9
|
+
}
|
|
10
|
+
describe('MatchGenerator.generateMatches()', () => {
|
|
11
|
+
it('plays a full double round robin for a 16-team division', () => {
|
|
12
|
+
const teams = Array.from({ length: 16 }, () => makeTeam());
|
|
13
|
+
const matches = MatchGenerator.generateMatches(teams, MONDAY);
|
|
14
|
+
// 16 teams x 15 opponents, home and away.
|
|
15
|
+
expect(matches).toHaveLength(240);
|
|
16
|
+
const seen = new Set(matches.map(orderedKey));
|
|
17
|
+
expect(seen.size).toBe(240);
|
|
18
|
+
// Every ordered pairing exists exactly once, so every team hosts and visits every other team.
|
|
19
|
+
for (const home of teams) {
|
|
20
|
+
for (const away of teams) {
|
|
21
|
+
if (home.id === away.id)
|
|
22
|
+
continue;
|
|
23
|
+
expect(seen.has(`${home.id}>${away.id}`)).toBe(true);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
it('anchors to 00:00 UTC of the base day and spaces matches 30 minutes apart', () => {
|
|
28
|
+
const teams = Array.from({ length: 16 }, () => makeTeam());
|
|
29
|
+
const matches = MatchGenerator.generateMatches(teams, new Date('2026-07-20T17:42:11.000Z'));
|
|
30
|
+
const times = matches.map(m => m.scheduledDate.getTime()).sort((a, b) => a - b);
|
|
31
|
+
expect(new Date(times[0]).toISOString()).toBe('2026-07-20T00:00:00.000Z');
|
|
32
|
+
for (let i = 1; i < times.length; i++) {
|
|
33
|
+
expect(times[i] - times[i - 1]).toBe(30 * 60 * 1000);
|
|
34
|
+
}
|
|
35
|
+
// 240 matches at half-hour spacing = five days, ending Friday 23:30 UTC.
|
|
36
|
+
expect(new Date(times[times.length - 1]).toISOString()).toBe('2026-07-24T23:30:00.000Z');
|
|
37
|
+
});
|
|
38
|
+
it('rejects a duplicated team and an empty division', () => {
|
|
39
|
+
const team = makeTeam();
|
|
40
|
+
expect(() => MatchGenerator.generateMatches([team, team], MONDAY)).toThrow('DUPLICATE_TEAMS');
|
|
41
|
+
expect(() => MatchGenerator.generateMatches([], MONDAY)).toThrow('EMPTY_TEAM_ARRAY');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Circle-method single round robin. Returns one entry per round, each holding the pairings that round plays,
|
|
3
|
+
* as `[home, away]` tuples. Every unordered pair of entrants appears exactly once across all rounds.
|
|
4
|
+
*
|
|
5
|
+
* An ODD entrant count produces a bye: one entrant sits out each round, so a round has `floor(n / 2)` pairings
|
|
6
|
+
* instead of `n / 2`. Callers that need everyone playing every round must pad to an even count first.
|
|
7
|
+
*
|
|
8
|
+
* Home and away alternate by round and by court index, so no entrant ends up at home in all of its games. For a
|
|
9
|
+
* double round robin the caller replays the same rounds with the sides swapped, which balances them exactly.
|
|
10
|
+
*/
|
|
11
|
+
export declare function roundRobinRounds<T>(entrants: T[]): Array<Array<[T, T]>>;
|